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 is_licensed = activation_status.to_lowercase().contains("licensed")
1409        && !activation_status.to_lowercase().contains("not licensed");
1410
1411    let mut out = String::from("Host inspection: fix_plan\n\n");
1412    out.push_str(&format!("- Requested issue: {}\n", issue));
1413    out.push_str("- Fix-plan type: windows_activation\n");
1414    if !activation_status.is_empty() {
1415        out.push_str(&format!(
1416            "- Current activation state:\n{}\n",
1417            activation_status
1418        ));
1419    }
1420
1421    if is_licensed {
1422        out.push_str(
1423            "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1424        );
1425        out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1426        out.push_str("   (Forces an online activation attempt)\n");
1427        out.push_str("2. Check activation details: slmgr /dli\n");
1428    } else {
1429        out.push_str("\nFix plan — Activating Windows:\n");
1430        out.push_str("1. Check your current status first:\n");
1431        out.push_str("   slmgr /dli   (basic info)\n");
1432        out.push_str("   slmgr /dlv   (detailed — shows remaining rearms, grace period)\n");
1433        out.push_str("\n2. If you have a retail product key:\n");
1434        out.push_str("   slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX   (install key)\n");
1435        out.push_str("   slmgr /ato                                   (activate online)\n");
1436        out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1437        out.push_str("   - Go to Settings → System → Activation\n");
1438        out.push_str("   - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1439        out.push_str("   - Sign in with the Microsoft account that holds the license\n");
1440        out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1441        out.push_str("   - Contact your IT department for the KMS server address\n");
1442        out.push_str("   - Set KMS host: slmgr /skms kms.yourorg.com\n");
1443        out.push_str("   - Activate:    slmgr /ato\n");
1444    }
1445    out.push_str("\nVerification:\n");
1446    out.push_str("  slmgr /dli   — should show 'License Status: Licensed'\n");
1447    out.push_str("  Or: Settings → System → Activation → 'Windows is activated'\n");
1448    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.");
1449    Ok(out.trim_end().to_string())
1450}
1451
1452fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1453    let mut out = String::from("Host inspection: fix_plan\n\n");
1454    out.push_str(&format!("- Requested issue: {}\n", issue));
1455    out.push_str("- Fix-plan type: registry_edit\n");
1456    out.push_str(
1457        "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1458    );
1459    out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1460    out.push_str("\n1. Back up before you touch anything:\n");
1461    out.push_str("   # Export the key you're about to change (PowerShell):\n");
1462    out.push_str("   reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1463    out.push_str("   # Or export the whole registry (takes a while):\n");
1464    out.push_str("   reg export HKLM C:\\backup\\HKLM_full.reg\n");
1465    out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1466    out.push_str("   Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1467    out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1468    out.push_str(
1469        "   Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1470    );
1471    out.push_str("\n4. Create a new key:\n");
1472    out.push_str("   New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1473    out.push_str("\n5. Delete a value:\n");
1474    out.push_str("   Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1475    out.push_str("\n6. Restore from backup if something breaks:\n");
1476    out.push_str("   reg import C:\\backup\\MyKey_backup.reg\n");
1477    out.push_str("\nCommon registry hives:\n");
1478    out.push_str("  HKLM = HKEY_LOCAL_MACHINE  (machine-wide, requires Admin)\n");
1479    out.push_str("  HKCU = HKEY_CURRENT_USER   (current user, no elevation needed)\n");
1480    out.push_str("  HKCR = HKEY_CLASSES_ROOT    (file associations)\n");
1481    out.push_str("\nVerification:\n");
1482    out.push_str("  Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1483    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.");
1484    Ok(out.trim_end().to_string())
1485}
1486
1487fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1488    let mut out = String::from("Host inspection: fix_plan\n\n");
1489    out.push_str(&format!("- Requested issue: {}\n", issue));
1490    out.push_str("- Fix-plan type: scheduled_task_create\n");
1491    out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1492    out.push_str("\nExample: Run a script at 9 AM every day\n");
1493    out.push_str("  $action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1494    out.push_str("  $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1495    out.push_str("  Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1496    out.push_str("\nExample: Run at Windows startup\n");
1497    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1498    out.push_str("  Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1499    out.push_str("\nExample: Run at user logon\n");
1500    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1501    out.push_str(
1502        "  Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1503    );
1504    out.push_str("\nExample: Run every 30 minutes\n");
1505    out.push_str("  $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1506    out.push_str("\nView all tasks:\n");
1507    out.push_str("  Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1508    out.push_str("\nDelete a task:\n");
1509    out.push_str("  Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1510    out.push_str("\nRun a task immediately:\n");
1511    out.push_str("  Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1512    out.push_str("\nVerification:\n");
1513    out.push_str("  Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1514    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.");
1515    Ok(out.trim_end().to_string())
1516}
1517
1518fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1519    #[cfg(target_os = "windows")]
1520    let disk_info = {
1521        Command::new("powershell")
1522            .args([
1523                "-NoProfile",
1524                "-NonInteractive",
1525                "-Command",
1526                "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\" }",
1527            ])
1528            .output()
1529            .ok()
1530            .and_then(|o| String::from_utf8(o.stdout).ok())
1531            .unwrap_or_default()
1532            .trim()
1533            .to_string()
1534    };
1535    #[cfg(not(target_os = "windows"))]
1536    let disk_info = String::new();
1537
1538    let mut out = String::from("Host inspection: fix_plan\n\n");
1539    out.push_str(&format!("- Requested issue: {}\n", issue));
1540    out.push_str("- Fix-plan type: disk_cleanup\n");
1541    if !disk_info.is_empty() {
1542        out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1543    }
1544    out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1545    out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1546    out.push_str("   cleanmgr /sageset:1    (configure what to clean)\n");
1547    out.push_str("   cleanmgr /sagerun:1    (run the cleanup)\n");
1548    out.push_str("   Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1549    out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1550    out.push_str("   Stop-Service wuauserv\n");
1551    out.push_str("   Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1552    out.push_str("   Start-Service wuauserv\n");
1553    out.push_str("\n3. Clear Windows Temp folder:\n");
1554    out.push_str("   Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1555    out.push_str(
1556        "   Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1557    );
1558    out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1559    out.push_str("   - Rust build artifacts: cargo clean  (inside each project)\n");
1560    out.push_str("   - npm cache:  npm cache clean --force\n");
1561    out.push_str("   - pip cache:  pip cache purge\n");
1562    out.push_str(
1563        "   - Docker:     docker system prune -a  (removes all unused images/containers)\n",
1564    );
1565    out.push_str("   - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force  (will redownload on next build)\n");
1566    out.push_str("\n5. Check for large files:\n");
1567    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");
1568    out.push_str("\nVerification:\n");
1569    out.push_str(
1570        "  Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1571    );
1572    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.");
1573    Ok(out.trim_end().to_string())
1574}
1575
1576fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1577    let mut out = String::from("Host inspection: fix_plan\n\n");
1578    out.push_str(&format!("- Requested issue: {}\n", issue));
1579    out.push_str("- Fix-plan type: generic\n");
1580    out.push_str(
1581        "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1582         Structured lanes available:\n\
1583         - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1584         - Port conflict (address already in use, what owns port)\n\
1585         - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1586         - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1587         - Group Policy (gpedit, local policy, administrative template)\n\
1588         - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1589         - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1590         - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1591         - Service config (start/stop/restart/enable/disable a service)\n\
1592         - Windows activation (product key, not activated, kms)\n\
1593         - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1594         - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1595         - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1596         - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1597    );
1598    Ok(out.trim_end().to_string())
1599}
1600
1601fn inspect_resource_load() -> Result<String, String> {
1602    #[cfg(target_os = "windows")]
1603    {
1604        let output = Command::new("powershell")
1605            .args([
1606                "-NoProfile",
1607                "-Command",
1608                "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1609            ])
1610            .output()
1611            .map_err(|e| format!("Failed to run powershell: {e}"))?;
1612
1613        let text = String::from_utf8_lossy(&output.stdout);
1614        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1615
1616        let cpu_load = lines
1617            .next()
1618            .and_then(|l| l.parse::<u32>().ok())
1619            .unwrap_or(0);
1620        let mem_json = lines.collect::<Vec<_>>().join("");
1621        let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1622
1623        let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1624        let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1625        let used_kb = total_kb.saturating_sub(free_kb);
1626        let mem_percent = if total_kb > 0 {
1627            (used_kb * 100) / total_kb
1628        } else {
1629            0
1630        };
1631
1632        let mut out = String::from("Host inspection: resource_load\n\n");
1633        out.push_str("**System Performance Summary:**\n");
1634        out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1635        out.push_str(&format!(
1636            "- Memory Usage: {} / {} ({}%)\n",
1637            human_bytes(used_kb * 1024),
1638            human_bytes(total_kb * 1024),
1639            mem_percent
1640        ));
1641
1642        if cpu_load > 85 {
1643            out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1644        }
1645        if mem_percent > 90 {
1646            out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1647        }
1648
1649        Ok(out)
1650    }
1651    #[cfg(not(target_os = "windows"))]
1652    {
1653        Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1654    }
1655}
1656
1657#[derive(Debug)]
1658enum EndpointProbe {
1659    Reachable(u16),
1660    Unreachable(String),
1661}
1662
1663async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1664    let client = match reqwest::Client::builder()
1665        .timeout(std::time::Duration::from_secs(3))
1666        .build()
1667    {
1668        Ok(client) => client,
1669        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1670    };
1671
1672    match client.get(url).send().await {
1673        Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1674        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1675    }
1676}
1677
1678async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1679    if configured_api.contains("11434") {
1680        let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1681        let url = format!("{}/api/ps", base);
1682        let client = reqwest::Client::builder()
1683            .timeout(std::time::Duration::from_secs(3))
1684            .build()
1685            .ok()?;
1686        let response = client.get(url).send().await.ok()?;
1687        let body = response.json::<serde_json::Value>().await.ok()?;
1688        let entries = body["models"].as_array()?;
1689        for entry in entries {
1690            let name = entry["name"]
1691                .as_str()
1692                .or_else(|| entry["model"].as_str())
1693                .unwrap_or_default();
1694            let lower = name.to_ascii_lowercase();
1695            if lower.contains("embed")
1696                || lower.contains("embedding")
1697                || lower.contains("minilm")
1698                || lower.contains("bge")
1699                || lower.contains("e5")
1700            {
1701                return Some(name.to_string());
1702            }
1703        }
1704        return None;
1705    }
1706
1707    let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1708    let url = format!("{}/api/v0/models", base);
1709    let client = reqwest::Client::builder()
1710        .timeout(std::time::Duration::from_secs(3))
1711        .build()
1712        .ok()?;
1713
1714    #[derive(serde::Deserialize)]
1715    struct ModelList {
1716        data: Vec<ModelEntry>,
1717    }
1718    #[derive(serde::Deserialize)]
1719    struct ModelEntry {
1720        id: String,
1721        #[serde(rename = "type", default)]
1722        model_type: String,
1723        #[serde(default)]
1724        state: String,
1725    }
1726
1727    let response = client.get(url).send().await.ok()?;
1728    let models = response.json::<ModelList>().await.ok()?;
1729    models
1730        .data
1731        .into_iter()
1732        .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1733        .map(|model| model.id)
1734}
1735
1736fn first_port_in_text(text: &str) -> Option<u16> {
1737    text.split(|c: char| !c.is_ascii_digit())
1738        .find(|fragment| !fragment.is_empty())
1739        .and_then(|fragment| fragment.parse::<u16>().ok())
1740}
1741
1742fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1743    let mut processes = collect_processes()?;
1744    if let Some(filter) = name_filter.as_deref() {
1745        let lowered = filter.to_ascii_lowercase();
1746        processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1747    }
1748    processes.sort_by(|a, b| {
1749        let a_cpu = a.cpu_percent.unwrap_or(0.0);
1750        let b_cpu = b.cpu_percent.unwrap_or(0.0);
1751        b_cpu
1752            .partial_cmp(&a_cpu)
1753            .unwrap_or(std::cmp::Ordering::Equal)
1754            .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1755            .then_with(|| a.name.cmp(&b.name))
1756            .then_with(|| a.pid.cmp(&b.pid))
1757    });
1758
1759    let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1760
1761    let mut out = String::from("Host inspection: processes\n\n");
1762    if let Some(filter) = name_filter.as_deref() {
1763        out.push_str(&format!("- Filter name: {}\n", filter));
1764    }
1765    out.push_str(&format!("- Processes found: {}\n", processes.len()));
1766    out.push_str(&format!(
1767        "- Total reported working set: {}\n",
1768        human_bytes(total_memory)
1769    ));
1770
1771    if processes.is_empty() {
1772        out.push_str("\nNo running processes matched.");
1773        return Ok(out);
1774    }
1775
1776    out.push_str("\nTop processes by resource usage:\n");
1777    for entry in processes.iter().take(max_entries) {
1778        let cpu_str = entry
1779            .cpu_percent
1780            .map(|p| format!(" [CPU: {:.1}%]", p))
1781            .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1782            .unwrap_or_default();
1783        let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1784            format!(" [I/O R:{}/W:{}]", r, w)
1785        } else {
1786            " [I/O unknown]".to_string()
1787        };
1788        out.push_str(&format!(
1789            "- {} (pid {}) - {}{}{}{}\n",
1790            entry.name,
1791            entry.pid,
1792            human_bytes(entry.memory_bytes),
1793            cpu_str,
1794            io_str,
1795            entry
1796                .detail
1797                .as_deref()
1798                .map(|detail| format!(" [{}]", detail))
1799                .unwrap_or_default()
1800        ));
1801    }
1802    if processes.len() > max_entries {
1803        out.push_str(&format!(
1804            "- ... {} more processes omitted\n",
1805            processes.len() - max_entries
1806        ));
1807    }
1808
1809    Ok(out.trim_end().to_string())
1810}
1811
1812fn inspect_network(max_entries: usize) -> Result<String, String> {
1813    let adapters = collect_network_adapters()?;
1814    let active_count = adapters
1815        .iter()
1816        .filter(|adapter| adapter.is_active())
1817        .count();
1818    let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1819
1820    let mut out = String::from("Host inspection: network\n\n");
1821    out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1822    out.push_str(&format!("- Active adapters: {}\n", active_count));
1823    out.push_str(&format!(
1824        "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1825        exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1826    ));
1827
1828    if adapters.is_empty() {
1829        out.push_str("\nNo adapter details were detected.");
1830        return Ok(out);
1831    }
1832
1833    out.push_str("\nAdapter summary:\n");
1834    for adapter in adapters.iter().take(max_entries) {
1835        let status = if adapter.is_active() {
1836            "active"
1837        } else if adapter.disconnected {
1838            "disconnected"
1839        } else {
1840            "idle"
1841        };
1842        let mut details = vec![status.to_string()];
1843        if !adapter.ipv4.is_empty() {
1844            details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1845        }
1846        if !adapter.ipv6.is_empty() {
1847            details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1848        }
1849        if !adapter.gateways.is_empty() {
1850            details.push(format!("gateway {}", adapter.gateways.join(", ")));
1851        }
1852        if !adapter.dns_servers.is_empty() {
1853            details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1854        }
1855        out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1856    }
1857    if adapters.len() > max_entries {
1858        out.push_str(&format!(
1859            "- ... {} more adapters omitted\n",
1860            adapters.len() - max_entries
1861        ));
1862    }
1863
1864    Ok(out.trim_end().to_string())
1865}
1866
1867fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1868    let mut out = String::from("Host inspection: lan_discovery\n\n");
1869
1870    #[cfg(target_os = "windows")]
1871    {
1872        let n = max_entries.clamp(5, 20);
1873        let adapters = collect_network_adapters()?;
1874        let services = collect_services().unwrap_or_default();
1875        let active_adapters: Vec<&NetworkAdapter> = adapters
1876            .iter()
1877            .filter(|adapter| adapter.is_active())
1878            .collect();
1879        let gateways: Vec<String> = active_adapters
1880            .iter()
1881            .flat_map(|adapter| adapter.gateways.clone())
1882            .collect::<HashSet<_>>()
1883            .into_iter()
1884            .collect();
1885
1886        let neighbor_script = r#"
1887$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1888    Where-Object {
1889        $_.IPAddress -notlike '127.*' -and
1890        $_.IPAddress -notlike '169.254*' -and
1891        $_.State -notin @('Unreachable','Invalid')
1892    } |
1893    Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1894$neighbors | ConvertTo-Json -Compress
1895"#;
1896        let neighbor_text = Command::new("powershell")
1897            .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1898            .output()
1899            .ok()
1900            .and_then(|o| String::from_utf8(o.stdout).ok())
1901            .unwrap_or_default();
1902        let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1903            .into_iter()
1904            .take(n)
1905            .collect();
1906
1907        let listener_script = r#"
1908Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1909    Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1910    Select-Object LocalAddress, LocalPort, OwningProcess |
1911    ForEach-Object {
1912        $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1913        "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1914    }
1915"#;
1916        let listener_text = Command::new("powershell")
1917            .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1918            .output()
1919            .ok()
1920            .and_then(|o| String::from_utf8(o.stdout).ok())
1921            .unwrap_or_default();
1922        let listeners: Vec<(String, u16, String, String)> = listener_text
1923            .lines()
1924            .filter_map(|line| {
1925                let parts: Vec<&str> = line.trim().split('|').collect();
1926                if parts.len() < 4 {
1927                    return None;
1928                }
1929                Some((
1930                    parts[0].to_string(),
1931                    parts[1].parse::<u16>().ok()?,
1932                    parts[2].to_string(),
1933                    parts[3].to_string(),
1934                ))
1935            })
1936            .take(n)
1937            .collect();
1938
1939        let smb_mapping_script = r#"
1940Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1941    ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1942"#;
1943        let smb_mappings: Vec<String> = Command::new("powershell")
1944            .args([
1945                "-NoProfile",
1946                "-NonInteractive",
1947                "-Command",
1948                smb_mapping_script,
1949            ])
1950            .output()
1951            .ok()
1952            .and_then(|o| String::from_utf8(o.stdout).ok())
1953            .unwrap_or_default()
1954            .lines()
1955            .take(n)
1956            .map(|line| line.trim().to_string())
1957            .filter(|line| !line.is_empty())
1958            .collect();
1959
1960        let smb_connections_script = r#"
1961Get-SmbConnection -ErrorAction SilentlyContinue |
1962    Select-Object ServerName, ShareName, NumOpens |
1963    ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1964"#;
1965        let smb_connections: Vec<String> = Command::new("powershell")
1966            .args([
1967                "-NoProfile",
1968                "-NonInteractive",
1969                "-Command",
1970                smb_connections_script,
1971            ])
1972            .output()
1973            .ok()
1974            .and_then(|o| String::from_utf8(o.stdout).ok())
1975            .unwrap_or_default()
1976            .lines()
1977            .take(n)
1978            .map(|line| line.trim().to_string())
1979            .filter(|line| !line.is_empty())
1980            .collect();
1981
1982        let discovery_service_names = [
1983            "FDResPub",
1984            "fdPHost",
1985            "SSDPSRV",
1986            "upnphost",
1987            "LanmanServer",
1988            "LanmanWorkstation",
1989            "lmhosts",
1990        ];
1991        let discovery_services: Vec<&ServiceEntry> = services
1992            .iter()
1993            .filter(|entry| {
1994                discovery_service_names
1995                    .iter()
1996                    .any(|name| entry.name.eq_ignore_ascii_case(name))
1997            })
1998            .collect();
1999
2000        let mut findings = Vec::new();
2001        if active_adapters.is_empty() {
2002            findings.push(AuditFinding {
2003                finding: "No active LAN adapters were detected.".to_string(),
2004                impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
2005                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(),
2006            });
2007        }
2008
2009        let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
2010            .iter()
2011            .copied()
2012            .filter(|entry| {
2013                !entry.status.eq_ignore_ascii_case("running")
2014                    && !entry.status.eq_ignore_ascii_case("active")
2015            })
2016            .collect();
2017        if !stopped_discovery_services.is_empty() {
2018            let names = stopped_discovery_services
2019                .iter()
2020                .map(|entry| entry.name.as_str())
2021                .collect::<Vec<_>>()
2022                .join(", ");
2023            findings.push(AuditFinding {
2024                finding: format!("Discovery-related services are not running: {names}"),
2025                impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
2026                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(),
2027            });
2028        }
2029
2030        if listeners.is_empty() {
2031            findings.push(AuditFinding {
2032                finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2033                impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2034                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(),
2035            });
2036        }
2037
2038        if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2039            findings.push(AuditFinding {
2040                finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2041                impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2042                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(),
2043            });
2044        }
2045
2046        out.push_str("=== Findings ===\n");
2047        if findings.is_empty() {
2048            out.push_str(
2049                "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2050            );
2051            out.push_str("  Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2052            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");
2053        } else {
2054            for finding in &findings {
2055                out.push_str(&format!("- Finding: {}\n", finding.finding));
2056                out.push_str(&format!("  Impact: {}\n", finding.impact));
2057                out.push_str(&format!("  Fix: {}\n", finding.fix));
2058            }
2059        }
2060
2061        out.push_str("\n=== Active adapter and gateway summary ===\n");
2062        if active_adapters.is_empty() {
2063            out.push_str("- No active adapters detected.\n");
2064        } else {
2065            for adapter in active_adapters.iter().take(n) {
2066                let ipv4 = if adapter.ipv4.is_empty() {
2067                    "no IPv4".to_string()
2068                } else {
2069                    adapter.ipv4.join(", ")
2070                };
2071                let gateway = if adapter.gateways.is_empty() {
2072                    "no gateway".to_string()
2073                } else {
2074                    adapter.gateways.join(", ")
2075                };
2076                out.push_str(&format!(
2077                    "- {} | IPv4: {} | Gateway: {}\n",
2078                    adapter.name, ipv4, gateway
2079                ));
2080            }
2081        }
2082
2083        out.push_str("\n=== Neighborhood evidence ===\n");
2084        out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
2085        out.push_str(&format!(
2086            "- Neighbor entries observed: {}\n",
2087            neighbors.len()
2088        ));
2089        if neighbors.is_empty() {
2090            out.push_str("- No ARP/neighbor evidence retrieved.\n");
2091        } else {
2092            for (ip, mac, state, iface) in neighbors.iter().take(n) {
2093                out.push_str(&format!(
2094                    "- {} on {} | MAC: {} | State: {}\n",
2095                    ip, iface, mac, state
2096                ));
2097            }
2098        }
2099
2100        out.push_str("\n=== Discovery services ===\n");
2101        if discovery_services.is_empty() {
2102            out.push_str("- Discovery service status unavailable.\n");
2103        } else {
2104            for entry in discovery_services.iter().take(n) {
2105                let startup = entry.startup.as_deref().unwrap_or("unknown");
2106                out.push_str(&format!(
2107                    "- {} | Status: {} | Startup: {}\n",
2108                    entry.name, entry.status, startup
2109                ));
2110            }
2111        }
2112
2113        out.push_str("\n=== Discovery listener surface ===\n");
2114        if listeners.is_empty() {
2115            out.push_str("- No discovery-oriented UDP listeners detected.\n");
2116        } else {
2117            for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2118                let label = match *port {
2119                    137 => "NetBIOS Name Service",
2120                    138 => "NetBIOS Datagram",
2121                    1900 => "SSDP/UPnP",
2122                    5353 => "mDNS",
2123                    5355 => "LLMNR",
2124                    _ => "Discovery",
2125                };
2126                let proc_label = if proc_name.is_empty() {
2127                    "unknown".to_string()
2128                } else {
2129                    proc_name.clone()
2130                };
2131                out.push_str(&format!(
2132                    "- {}:{} | {} | PID {} ({})\n",
2133                    addr, port, label, pid, proc_label
2134                ));
2135            }
2136        }
2137
2138        out.push_str("\n=== SMB and neighborhood visibility ===\n");
2139        if smb_mappings.is_empty() && smb_connections.is_empty() {
2140            out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2141        } else {
2142            if !smb_mappings.is_empty() {
2143                out.push_str("- Mapped drives:\n");
2144                for mapping in smb_mappings.iter().take(n) {
2145                    let parts: Vec<&str> = mapping.split('|').collect();
2146                    if parts.len() >= 2 {
2147                        out.push_str(&format!("  - {} -> {}\n", parts[0], parts[1]));
2148                    }
2149                }
2150            }
2151            if !smb_connections.is_empty() {
2152                out.push_str("- Active SMB connections:\n");
2153                for connection in smb_connections.iter().take(n) {
2154                    let parts: Vec<&str> = connection.split('|').collect();
2155                    if parts.len() >= 3 {
2156                        out.push_str(&format!(
2157                            "  - {}\\{} | Opens: {}\n",
2158                            parts[0], parts[1], parts[2]
2159                        ));
2160                    }
2161                }
2162            }
2163        }
2164    }
2165
2166    #[cfg(not(target_os = "windows"))]
2167    {
2168        let n = max_entries.clamp(5, 20);
2169        let adapters = collect_network_adapters()?;
2170        let arp_output = Command::new("ip")
2171            .args(["neigh"])
2172            .output()
2173            .ok()
2174            .and_then(|o| String::from_utf8(o.stdout).ok())
2175            .unwrap_or_default();
2176        let neighbors: Vec<&str> = arp_output
2177            .lines()
2178            .filter(|line| !line.trim().is_empty())
2179            .take(n)
2180            .collect();
2181
2182        out.push_str("=== Findings ===\n");
2183        if adapters.iter().any(|adapter| adapter.is_active()) {
2184            out.push_str(
2185                "- Finding: LAN discovery support is partially available on this platform.\n",
2186            );
2187            out.push_str("  Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2188            out.push_str("  Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2189        } else {
2190            out.push_str("- Finding: No active LAN adapters were detected.\n");
2191            out.push_str(
2192                "  Impact: Neighborhood discovery cannot work without an active interface.\n",
2193            );
2194            out.push_str("  Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2195        }
2196
2197        out.push_str("\n=== Active adapter and gateway summary ===\n");
2198        if adapters.is_empty() {
2199            out.push_str("- No adapters detected.\n");
2200        } else {
2201            for adapter in adapters.iter().take(n) {
2202                let ipv4 = if adapter.ipv4.is_empty() {
2203                    "no IPv4".to_string()
2204                } else {
2205                    adapter.ipv4.join(", ")
2206                };
2207                let gateway = if adapter.gateways.is_empty() {
2208                    "no gateway".to_string()
2209                } else {
2210                    adapter.gateways.join(", ")
2211                };
2212                out.push_str(&format!(
2213                    "- {} | IPv4: {} | Gateway: {}\n",
2214                    adapter.name, ipv4, gateway
2215                ));
2216            }
2217        }
2218
2219        out.push_str("\n=== Neighborhood evidence ===\n");
2220        if neighbors.is_empty() {
2221            out.push_str("- No neighbor entries detected.\n");
2222        } else {
2223            for line in neighbors {
2224                out.push_str(&format!("- {}\n", line.trim()));
2225            }
2226        }
2227    }
2228
2229    Ok(out.trim_end().to_string())
2230}
2231
2232fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2233    let mut services = collect_services()?;
2234    if let Some(filter) = name_filter.as_deref() {
2235        let lowered = filter.to_ascii_lowercase();
2236        services.retain(|entry| {
2237            entry.name.to_ascii_lowercase().contains(&lowered)
2238                || entry
2239                    .display_name
2240                    .as_deref()
2241                    .map(|d| d.to_ascii_lowercase().contains(&lowered))
2242                    .unwrap_or(false)
2243        });
2244    }
2245
2246    services.sort_by(|a, b| {
2247        let a_running =
2248            a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2249        let b_running =
2250            b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2251        b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2252    });
2253
2254    let running = services
2255        .iter()
2256        .filter(|entry| {
2257            entry.status.eq_ignore_ascii_case("running")
2258                || entry.status.eq_ignore_ascii_case("active")
2259        })
2260        .count();
2261    let failed = services
2262        .iter()
2263        .filter(|entry| {
2264            entry.status.eq_ignore_ascii_case("failed")
2265                || entry.status.eq_ignore_ascii_case("error")
2266                || entry.status.eq_ignore_ascii_case("stopped")
2267        })
2268        .count();
2269
2270    let mut out = String::from("Host inspection: services\n\n");
2271    if let Some(filter) = name_filter.as_deref() {
2272        out.push_str(&format!("- Filter name: {}\n", filter));
2273    }
2274    out.push_str(&format!("- Services found: {}\n", services.len()));
2275    out.push_str(&format!("- Running/active: {}\n", running));
2276    out.push_str(&format!("- Failed/stopped: {}\n", failed));
2277
2278    if services.is_empty() {
2279        out.push_str("\nNo services matched.");
2280        return Ok(out);
2281    }
2282
2283    // Split into running and stopped sections so both are always visible.
2284    let per_section = (max_entries / 2).max(5);
2285
2286    let running_services: Vec<_> = services
2287        .iter()
2288        .filter(|e| {
2289            e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2290        })
2291        .collect();
2292    let stopped_services: Vec<_> = services
2293        .iter()
2294        .filter(|e| {
2295            e.status.eq_ignore_ascii_case("stopped")
2296                || e.status.eq_ignore_ascii_case("failed")
2297                || e.status.eq_ignore_ascii_case("error")
2298        })
2299        .collect();
2300
2301    let fmt_entry = |entry: &&ServiceEntry| {
2302        let startup = entry
2303            .startup
2304            .as_deref()
2305            .map(|v| format!(" | startup {}", v))
2306            .unwrap_or_default();
2307        let logon = entry
2308            .start_name
2309            .as_deref()
2310            .map(|v| format!(" | LogOn: {}", v))
2311            .unwrap_or_default();
2312        let display = entry
2313            .display_name
2314            .as_deref()
2315            .filter(|v| *v != &entry.name)
2316            .map(|v| format!(" [{}]", v))
2317            .unwrap_or_default();
2318        format!(
2319            "- {}{} - {}{}{}\n",
2320            entry.name, display, entry.status, startup, logon
2321        )
2322    };
2323
2324    out.push_str(&format!(
2325        "\nRunning services ({} total, showing up to {}):\n",
2326        running_services.len(),
2327        per_section
2328    ));
2329    for entry in running_services.iter().take(per_section) {
2330        out.push_str(&fmt_entry(entry));
2331    }
2332    if running_services.len() > per_section {
2333        out.push_str(&format!(
2334            "- ... {} more running services omitted\n",
2335            running_services.len() - per_section
2336        ));
2337    }
2338
2339    out.push_str(&format!(
2340        "\nStopped/failed services ({} total, showing up to {}):\n",
2341        stopped_services.len(),
2342        per_section
2343    ));
2344    for entry in stopped_services.iter().take(per_section) {
2345        out.push_str(&fmt_entry(entry));
2346    }
2347    if stopped_services.len() > per_section {
2348        out.push_str(&format!(
2349            "- ... {} more stopped services omitted\n",
2350            stopped_services.len() - per_section
2351        ));
2352    }
2353
2354    Ok(out.trim_end().to_string())
2355}
2356
2357async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2358    inspect_directory("Disk", path, max_entries).await
2359}
2360
2361fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2362    let mut listeners = collect_listening_ports()?;
2363    if let Some(port) = port_filter {
2364        listeners.retain(|entry| entry.port == port);
2365    }
2366    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2367
2368    let mut out = String::from("Host inspection: ports\n\n");
2369    if let Some(port) = port_filter {
2370        out.push_str(&format!("- Filter port: {}\n", port));
2371    }
2372    out.push_str(&format!(
2373        "- Listening endpoints found: {}\n",
2374        listeners.len()
2375    ));
2376
2377    if listeners.is_empty() {
2378        out.push_str("\nNo listening endpoints matched.");
2379        return Ok(out);
2380    }
2381
2382    out.push_str("\nListening endpoints:\n");
2383    for entry in listeners.iter().take(max_entries) {
2384        let pid_str = entry
2385            .pid
2386            .as_deref()
2387            .map(|p| format!(" pid {}", p))
2388            .unwrap_or_default();
2389        let name_str = entry
2390            .process_name
2391            .as_deref()
2392            .map(|n| format!(" [{}]", n))
2393            .unwrap_or_default();
2394        out.push_str(&format!(
2395            "- {} {} ({}){}{}\n",
2396            entry.protocol, entry.local, entry.state, pid_str, name_str
2397        ));
2398    }
2399    if listeners.len() > max_entries {
2400        out.push_str(&format!(
2401            "- ... {} more listening endpoints omitted\n",
2402            listeners.len() - max_entries
2403        ));
2404    }
2405
2406    Ok(out.trim_end().to_string())
2407}
2408
2409fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2410    if !path.exists() {
2411        return Err(format!("Path does not exist: {}", path.display()));
2412    }
2413    if !path.is_dir() {
2414        return Err(format!("Path is not a directory: {}", path.display()));
2415    }
2416
2417    let markers = collect_project_markers(&path);
2418    let hematite_state = collect_hematite_state(&path);
2419    let git_state = inspect_git_state(&path);
2420    let release_state = inspect_release_artifacts(&path);
2421
2422    let mut out = String::from("Host inspection: repo_doctor\n\n");
2423    out.push_str(&format!("- Path: {}\n", path.display()));
2424    out.push_str(&format!(
2425        "- Workspace mode: {}\n",
2426        workspace_mode_for_path(&path)
2427    ));
2428
2429    if markers.is_empty() {
2430        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");
2431    } else {
2432        out.push_str("- Project markers:\n");
2433        for marker in markers.iter().take(max_entries) {
2434            out.push_str(&format!("  - {}\n", marker));
2435        }
2436    }
2437
2438    match git_state {
2439        Some(git) => {
2440            out.push_str(&format!("- Git root: {}\n", git.root.display()));
2441            out.push_str(&format!("- Git branch: {}\n", git.branch));
2442            out.push_str(&format!("- Git status: {}\n", git.status_label()));
2443        }
2444        None => out.push_str("- Git: not inside a detected work tree\n"),
2445    }
2446
2447    out.push_str(&format!(
2448        "- Hematite docs/imports/reports: {}/{}/{}\n",
2449        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2450    ));
2451    if hematite_state.workspace_profile {
2452        out.push_str("- Workspace profile: present\n");
2453    } else {
2454        out.push_str("- Workspace profile: absent\n");
2455    }
2456
2457    if let Some(release) = release_state {
2458        out.push_str(&format!("- Cargo version: {}\n", release.version));
2459        out.push_str(&format!(
2460            "- Windows artifacts for current version: {}/{}/{}\n",
2461            bool_label(release.portable_dir),
2462            bool_label(release.portable_zip),
2463            bool_label(release.setup_exe)
2464        ));
2465    }
2466
2467    Ok(out.trim_end().to_string())
2468}
2469
2470async fn inspect_known_directory(
2471    label: &str,
2472    path: Option<PathBuf>,
2473    max_entries: usize,
2474) -> Result<String, String> {
2475    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2476    inspect_directory(label, path, max_entries).await
2477}
2478
2479async fn inspect_directory(
2480    label: &str,
2481    path: PathBuf,
2482    max_entries: usize,
2483) -> Result<String, String> {
2484    let label = label.to_string();
2485    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2486        .await
2487        .map_err(|e| format!("inspect_host task failed: {e}"))?
2488}
2489
2490fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2491    if !path.exists() {
2492        return Err(format!("Path does not exist: {}", path.display()));
2493    }
2494    if !path.is_dir() {
2495        return Err(format!("Path is not a directory: {}", path.display()));
2496    }
2497
2498    let mut top_level_entries = Vec::new();
2499    for entry in fs::read_dir(path)
2500        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2501    {
2502        match entry {
2503            Ok(entry) => top_level_entries.push(entry),
2504            Err(_) => continue,
2505        }
2506    }
2507    top_level_entries.sort_by_key(|entry| entry.file_name());
2508
2509    let top_level_count = top_level_entries.len();
2510    let mut sample_names = Vec::new();
2511    let mut largest_entries = Vec::new();
2512    let mut aggregate = PathAggregate::default();
2513    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2514
2515    for entry in top_level_entries {
2516        let name = entry.file_name().to_string_lossy().to_string();
2517        if sample_names.len() < max_entries {
2518            sample_names.push(name.clone());
2519        }
2520        let kind = match entry.file_type() {
2521            Ok(ft) if ft.is_dir() => "dir",
2522            Ok(ft) if ft.is_symlink() => "symlink",
2523            _ => "file",
2524        };
2525        let stats = measure_path(&entry.path(), &mut budget);
2526        aggregate.merge(&stats);
2527        largest_entries.push(LargestEntry {
2528            name,
2529            kind,
2530            bytes: stats.total_bytes,
2531        });
2532    }
2533
2534    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2535
2536    let mut out = format!("Directory inspection: {}\n\n", label);
2537    out.push_str(&format!("- Path: {}\n", path.display()));
2538    out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2539    out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2540    out.push_str(&format!(
2541        "- Recursive directories: {}\n",
2542        aggregate.dir_count
2543    ));
2544    out.push_str(&format!(
2545        "- Total size: {}{}\n",
2546        human_bytes(aggregate.total_bytes),
2547        if aggregate.partial {
2548            " (partial scan)"
2549        } else {
2550            ""
2551        }
2552    ));
2553    if aggregate.skipped_entries > 0 {
2554        out.push_str(&format!(
2555            "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2556            aggregate.skipped_entries
2557        ));
2558    }
2559
2560    if !largest_entries.is_empty() {
2561        out.push_str("\nLargest top-level entries:\n");
2562        for entry in largest_entries.iter().take(max_entries) {
2563            out.push_str(&format!(
2564                "- {} [{}] - {}\n",
2565                entry.name,
2566                entry.kind,
2567                human_bytes(entry.bytes)
2568            ));
2569        }
2570    }
2571
2572    if !sample_names.is_empty() {
2573        out.push_str("\nSample names:\n");
2574        for name in sample_names {
2575            out.push_str(&format!("- {}\n", name));
2576        }
2577    }
2578
2579    Ok(out.trim_end().to_string())
2580}
2581
2582fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2583    let trimmed = raw.trim();
2584    if trimmed.is_empty() {
2585        return Err("Path must not be empty.".to_string());
2586    }
2587
2588    if let Some(rest) = trimmed
2589        .strip_prefix("~/")
2590        .or_else(|| trimmed.strip_prefix("~\\"))
2591    {
2592        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2593        return Ok(home.join(rest));
2594    }
2595
2596    let path = PathBuf::from(trimmed);
2597    if path.is_absolute() {
2598        Ok(path)
2599    } else {
2600        let cwd =
2601            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2602        let full_path = cwd.join(&path);
2603
2604        // Heuristic: If it's a relative path to .hematite or hematite.exe and doesn't exist here,
2605        // check the user's home directory.
2606        if !full_path.exists()
2607            && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2608        {
2609            if let Some(home) = home::home_dir() {
2610                let home_path = home.join(trimmed);
2611                if home_path.exists() {
2612                    return Ok(home_path);
2613                }
2614            }
2615        }
2616
2617        Ok(full_path)
2618    }
2619}
2620
2621fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2622    workspace_mode_for_path(workspace_root)
2623}
2624
2625fn workspace_mode_for_path(path: &Path) -> &'static str {
2626    if is_project_marker_path(path) {
2627        "project"
2628    } else if path.join(".hematite").join("docs").exists()
2629        || path.join(".hematite").join("imports").exists()
2630        || path.join(".hematite").join("reports").exists()
2631    {
2632        "docs-only"
2633    } else {
2634        "general directory"
2635    }
2636}
2637
2638fn is_project_marker_path(path: &Path) -> bool {
2639    [
2640        "Cargo.toml",
2641        "package.json",
2642        "pyproject.toml",
2643        "go.mod",
2644        "composer.json",
2645        "requirements.txt",
2646        "Makefile",
2647        "justfile",
2648    ]
2649    .iter()
2650    .any(|name| path.join(name).exists())
2651        || path.join(".git").exists()
2652}
2653
2654fn preferred_shell_label() -> &'static str {
2655    #[cfg(target_os = "windows")]
2656    {
2657        "PowerShell"
2658    }
2659    #[cfg(not(target_os = "windows"))]
2660    {
2661        "sh"
2662    }
2663}
2664
2665fn desktop_dir() -> Option<PathBuf> {
2666    home::home_dir().map(|home| home.join("Desktop"))
2667}
2668
2669fn downloads_dir() -> Option<PathBuf> {
2670    home::home_dir().map(|home| home.join("Downloads"))
2671}
2672
2673fn count_top_level_items(path: &Path) -> Result<usize, String> {
2674    let mut count = 0usize;
2675    for entry in
2676        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2677    {
2678        if entry.is_ok() {
2679            count += 1;
2680        }
2681    }
2682    Ok(count)
2683}
2684
2685#[derive(Default)]
2686struct PathAggregate {
2687    total_bytes: u64,
2688    file_count: u64,
2689    dir_count: u64,
2690    skipped_entries: u64,
2691    partial: bool,
2692}
2693
2694impl PathAggregate {
2695    fn merge(&mut self, other: &PathAggregate) {
2696        self.total_bytes += other.total_bytes;
2697        self.file_count += other.file_count;
2698        self.dir_count += other.dir_count;
2699        self.skipped_entries += other.skipped_entries;
2700        self.partial |= other.partial;
2701    }
2702}
2703
2704struct LargestEntry {
2705    name: String,
2706    kind: &'static str,
2707    bytes: u64,
2708}
2709
2710fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2711    if *budget == 0 {
2712        return PathAggregate {
2713            partial: true,
2714            skipped_entries: 1,
2715            ..PathAggregate::default()
2716        };
2717    }
2718    *budget -= 1;
2719
2720    let metadata = match fs::symlink_metadata(path) {
2721        Ok(metadata) => metadata,
2722        Err(_) => {
2723            return PathAggregate {
2724                skipped_entries: 1,
2725                ..PathAggregate::default()
2726            }
2727        }
2728    };
2729
2730    let file_type = metadata.file_type();
2731    if file_type.is_symlink() {
2732        return PathAggregate {
2733            skipped_entries: 1,
2734            ..PathAggregate::default()
2735        };
2736    }
2737
2738    if metadata.is_file() {
2739        return PathAggregate {
2740            total_bytes: metadata.len(),
2741            file_count: 1,
2742            ..PathAggregate::default()
2743        };
2744    }
2745
2746    if !metadata.is_dir() {
2747        return PathAggregate::default();
2748    }
2749
2750    let mut aggregate = PathAggregate {
2751        dir_count: 1,
2752        ..PathAggregate::default()
2753    };
2754
2755    let read_dir = match fs::read_dir(path) {
2756        Ok(read_dir) => read_dir,
2757        Err(_) => {
2758            aggregate.skipped_entries += 1;
2759            return aggregate;
2760        }
2761    };
2762
2763    for child in read_dir {
2764        match child {
2765            Ok(child) => {
2766                let child_stats = measure_path(&child.path(), budget);
2767                aggregate.merge(&child_stats);
2768            }
2769            Err(_) => aggregate.skipped_entries += 1,
2770        }
2771    }
2772
2773    aggregate
2774}
2775
2776struct PathAnalysis {
2777    total_entries: usize,
2778    unique_entries: usize,
2779    entries: Vec<String>,
2780    duplicate_entries: Vec<String>,
2781    missing_entries: Vec<String>,
2782}
2783
2784fn analyze_path_env() -> PathAnalysis {
2785    let mut entries = Vec::new();
2786    let mut duplicate_entries = Vec::new();
2787    let mut missing_entries = Vec::new();
2788    let mut seen = HashSet::new();
2789
2790    let raw_path = std::env::var_os("PATH").unwrap_or_default();
2791    for path in std::env::split_paths(&raw_path) {
2792        let display = path.display().to_string();
2793        if display.trim().is_empty() {
2794            continue;
2795        }
2796
2797        let normalized = normalize_path_entry(&display);
2798        if !seen.insert(normalized) {
2799            duplicate_entries.push(display.clone());
2800        }
2801        if !path.exists() {
2802            missing_entries.push(display.clone());
2803        }
2804        entries.push(display);
2805    }
2806
2807    let total_entries = entries.len();
2808    let unique_entries = seen.len();
2809
2810    PathAnalysis {
2811        total_entries,
2812        unique_entries,
2813        entries,
2814        duplicate_entries,
2815        missing_entries,
2816    }
2817}
2818
2819fn normalize_path_entry(value: &str) -> String {
2820    #[cfg(target_os = "windows")]
2821    {
2822        value
2823            .replace('/', "\\")
2824            .trim_end_matches(['\\', '/'])
2825            .to_ascii_lowercase()
2826    }
2827    #[cfg(not(target_os = "windows"))]
2828    {
2829        value.trim_end_matches('/').to_string()
2830    }
2831}
2832
2833struct ToolchainReport {
2834    found: Vec<(String, String)>,
2835    missing: Vec<String>,
2836}
2837
2838struct PackageManagerReport {
2839    found: Vec<(String, String)>,
2840}
2841
2842#[derive(Debug, Clone)]
2843struct ProcessEntry {
2844    name: String,
2845    pid: u32,
2846    memory_bytes: u64,
2847    cpu_seconds: Option<f64>,
2848    cpu_percent: Option<f64>,
2849    read_ops: Option<u64>,
2850    write_ops: Option<u64>,
2851    detail: Option<String>,
2852}
2853
2854#[derive(Debug, Clone)]
2855struct ServiceEntry {
2856    name: String,
2857    status: String,
2858    startup: Option<String>,
2859    display_name: Option<String>,
2860    start_name: Option<String>,
2861}
2862
2863#[derive(Debug, Clone, Default)]
2864struct NetworkAdapter {
2865    name: String,
2866    ipv4: Vec<String>,
2867    ipv6: Vec<String>,
2868    gateways: Vec<String>,
2869    dns_servers: Vec<String>,
2870    disconnected: bool,
2871}
2872
2873impl NetworkAdapter {
2874    fn is_active(&self) -> bool {
2875        !self.disconnected
2876            && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2877    }
2878}
2879
2880#[derive(Debug, Clone, Copy, Default)]
2881struct ListenerExposureSummary {
2882    loopback_only: usize,
2883    wildcard_public: usize,
2884    specific_bind: usize,
2885}
2886
2887#[derive(Debug, Clone)]
2888struct ListeningPort {
2889    protocol: String,
2890    local: String,
2891    port: u16,
2892    state: String,
2893    pid: Option<String>,
2894    process_name: Option<String>,
2895}
2896
2897fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2898    #[cfg(target_os = "windows")]
2899    {
2900        collect_windows_listening_ports()
2901    }
2902    #[cfg(not(target_os = "windows"))]
2903    {
2904        collect_unix_listening_ports()
2905    }
2906}
2907
2908fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2909    #[cfg(target_os = "windows")]
2910    {
2911        collect_windows_network_adapters()
2912    }
2913    #[cfg(not(target_os = "windows"))]
2914    {
2915        collect_unix_network_adapters()
2916    }
2917}
2918
2919fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2920    #[cfg(target_os = "windows")]
2921    {
2922        collect_windows_services()
2923    }
2924    #[cfg(not(target_os = "windows"))]
2925    {
2926        collect_unix_services()
2927    }
2928}
2929
2930#[cfg(target_os = "windows")]
2931fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2932    let output = Command::new("netstat")
2933        .args(["-ano", "-p", "tcp"])
2934        .output()
2935        .map_err(|e| format!("Failed to run netstat: {e}"))?;
2936    if !output.status.success() {
2937        return Err("netstat returned a non-success status.".to_string());
2938    }
2939
2940    let text = String::from_utf8_lossy(&output.stdout);
2941    let mut listeners = Vec::new();
2942    for line in text.lines() {
2943        let trimmed = line.trim();
2944        if !trimmed.starts_with("TCP") {
2945            continue;
2946        }
2947        let cols: Vec<&str> = trimmed.split_whitespace().collect();
2948        if cols.len() < 5 || cols[3] != "LISTENING" {
2949            continue;
2950        }
2951        let Some(port) = extract_port_from_socket(cols[1]) else {
2952            continue;
2953        };
2954        listeners.push(ListeningPort {
2955            protocol: cols[0].to_string(),
2956            local: cols[1].to_string(),
2957            port,
2958            state: cols[3].to_string(),
2959            pid: Some(cols[4].to_string()),
2960            process_name: None,
2961        });
2962    }
2963
2964    // Enrich with process names via PowerShell — works without elevation for
2965    // most user-space processes. System processes (PID 4, etc.) stay unnamed.
2966    let unique_pids: Vec<String> = listeners
2967        .iter()
2968        .filter_map(|l| l.pid.clone())
2969        .collect::<HashSet<_>>()
2970        .into_iter()
2971        .collect();
2972
2973    if !unique_pids.is_empty() {
2974        let pid_list = unique_pids.join(",");
2975        let ps_cmd = format!(
2976            "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2977            pid_list
2978        );
2979        if let Ok(ps_out) = Command::new("powershell")
2980            .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2981            .output()
2982        {
2983            let mut pid_map = std::collections::HashMap::<String, String>::new();
2984            let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2985            for line in ps_text.lines() {
2986                let parts: Vec<&str> = line.split_whitespace().collect();
2987                if parts.len() >= 2 {
2988                    pid_map.insert(parts[0].to_string(), parts[1].to_string());
2989                }
2990            }
2991            for listener in &mut listeners {
2992                if let Some(pid) = &listener.pid {
2993                    listener.process_name = pid_map.get(pid).cloned();
2994                }
2995            }
2996        }
2997    }
2998
2999    Ok(listeners)
3000}
3001
3002#[cfg(not(target_os = "windows"))]
3003fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
3004    let output = Command::new("ss")
3005        .args(["-ltn"])
3006        .output()
3007        .map_err(|e| format!("Failed to run ss: {e}"))?;
3008    if !output.status.success() {
3009        return Err("ss returned a non-success status.".to_string());
3010    }
3011
3012    let text = String::from_utf8_lossy(&output.stdout);
3013    let mut listeners = Vec::new();
3014    for line in text.lines().skip(1) {
3015        let cols: Vec<&str> = line.split_whitespace().collect();
3016        if cols.len() < 4 {
3017            continue;
3018        }
3019        let Some(port) = extract_port_from_socket(cols[3]) else {
3020            continue;
3021        };
3022        listeners.push(ListeningPort {
3023            protocol: "tcp".to_string(),
3024            local: cols[3].to_string(),
3025            port,
3026            state: cols[0].to_string(),
3027            pid: None,
3028            process_name: None,
3029        });
3030    }
3031
3032    Ok(listeners)
3033}
3034
3035fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3036    #[cfg(target_os = "windows")]
3037    {
3038        collect_windows_processes()
3039    }
3040    #[cfg(not(target_os = "windows"))]
3041    {
3042        collect_unix_processes()
3043    }
3044}
3045
3046#[cfg(target_os = "windows")]
3047fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3048    let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3049    let output = Command::new("powershell")
3050        .args(["-NoProfile", "-Command", command])
3051        .output()
3052        .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3053    if !output.status.success() {
3054        return Err("PowerShell service inspection returned a non-success status.".to_string());
3055    }
3056
3057    parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3058}
3059
3060#[cfg(not(target_os = "windows"))]
3061fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3062    let status_output = Command::new("systemctl")
3063        .args([
3064            "list-units",
3065            "--type=service",
3066            "--all",
3067            "--no-pager",
3068            "--no-legend",
3069            "--plain",
3070        ])
3071        .output()
3072        .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3073    if !status_output.status.success() {
3074        return Err("systemctl list-units returned a non-success status.".to_string());
3075    }
3076
3077    let startup_output = Command::new("systemctl")
3078        .args([
3079            "list-unit-files",
3080            "--type=service",
3081            "--no-legend",
3082            "--no-pager",
3083            "--plain",
3084        ])
3085        .output()
3086        .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3087    if !startup_output.status.success() {
3088        return Err("systemctl list-unit-files returned a non-success status.".to_string());
3089    }
3090
3091    Ok(parse_unix_services(
3092        &String::from_utf8_lossy(&status_output.stdout),
3093        &String::from_utf8_lossy(&startup_output.stdout),
3094    ))
3095}
3096
3097#[cfg(target_os = "windows")]
3098fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3099    let output = Command::new("ipconfig")
3100        .args(["/all"])
3101        .output()
3102        .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3103    if !output.status.success() {
3104        return Err("ipconfig returned a non-success status.".to_string());
3105    }
3106
3107    Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3108        &output.stdout,
3109    )))
3110}
3111
3112#[cfg(not(target_os = "windows"))]
3113fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3114    let addr_output = Command::new("ip")
3115        .args(["-o", "addr", "show", "up"])
3116        .output()
3117        .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3118    if !addr_output.status.success() {
3119        return Err("ip addr returned a non-success status.".to_string());
3120    }
3121
3122    let route_output = Command::new("ip")
3123        .args(["route", "show", "default"])
3124        .output()
3125        .map_err(|e| format!("Failed to run ip route: {e}"))?;
3126    if !route_output.status.success() {
3127        return Err("ip route returned a non-success status.".to_string());
3128    }
3129
3130    let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3131    apply_unix_default_routes(
3132        &mut adapters,
3133        &String::from_utf8_lossy(&route_output.stdout),
3134    );
3135    apply_unix_dns_servers(&mut adapters);
3136    Ok(adapters)
3137}
3138
3139#[cfg(target_os = "windows")]
3140fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3141    // We take two samples of CPU time separated by a short interval to calculate recent CPU %
3142    let script = r#"
3143        $s1 = Get-Process | Select-Object Id, CPU
3144        Start-Sleep -Milliseconds 250
3145        $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3146        $s2 | ForEach-Object {
3147            $p2 = $_
3148            $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3149            $pct = 0.0
3150            if ($p1 -and $p2.CPU -gt $p1.CPU) {
3151                # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3152                # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3153                # Standard Task Manager style is (delta / interval) * 100.
3154                $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3155            }
3156            "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3157        }
3158    "#;
3159
3160    let output = Command::new("powershell")
3161        .args(["-NoProfile", "-Command", script])
3162        .output()
3163        .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3164
3165    let text = String::from_utf8_lossy(&output.stdout);
3166    let mut out = Vec::new();
3167    for line in text.lines() {
3168        let parts: Vec<&str> = line.trim().split('|').collect();
3169        if parts.len() < 5 {
3170            continue;
3171        }
3172        let mut entry = ProcessEntry {
3173            name: "unknown".to_string(),
3174            pid: 0,
3175            memory_bytes: 0,
3176            cpu_seconds: None,
3177            cpu_percent: None,
3178            read_ops: None,
3179            write_ops: None,
3180            detail: None,
3181        };
3182        for p in parts {
3183            if let Some((k, v)) = p.split_once(':') {
3184                match k {
3185                    "PID" => entry.pid = v.parse().unwrap_or(0),
3186                    "NAME" => entry.name = v.to_string(),
3187                    "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3188                    "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3189                    "CPU_P" => entry.cpu_percent = v.parse().ok(),
3190                    "READ" => entry.read_ops = v.parse().ok(),
3191                    "WRITE" => entry.write_ops = v.parse().ok(),
3192                    _ => {}
3193                }
3194            }
3195        }
3196        out.push(entry);
3197    }
3198    Ok(out)
3199}
3200
3201#[cfg(not(target_os = "windows"))]
3202fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3203    let output = Command::new("ps")
3204        .args(["-eo", "pid=,rss=,comm="])
3205        .output()
3206        .map_err(|e| format!("Failed to run ps: {e}"))?;
3207    if !output.status.success() {
3208        return Err("ps returned a non-success status.".to_string());
3209    }
3210
3211    let text = String::from_utf8_lossy(&output.stdout);
3212    let mut processes = Vec::new();
3213    for line in text.lines() {
3214        let cols: Vec<&str> = line.split_whitespace().collect();
3215        if cols.len() < 3 {
3216            continue;
3217        }
3218        let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
3219        else {
3220            continue;
3221        };
3222        processes.push(ProcessEntry {
3223            name: cols[2..].join(" "),
3224            pid,
3225            memory_bytes: rss_kib * 1024,
3226            cpu_seconds: None,
3227            cpu_percent: None,
3228            read_ops: None,
3229            write_ops: None,
3230            detail: None,
3231        });
3232    }
3233
3234    Ok(processes)
3235}
3236
3237fn extract_port_from_socket(value: &str) -> Option<u16> {
3238    let cleaned = value.trim().trim_matches(['[', ']']);
3239    let port_str = cleaned.rsplit(':').next()?;
3240    port_str.parse::<u16>().ok()
3241}
3242
3243fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3244    let mut summary = ListenerExposureSummary::default();
3245    for entry in listeners {
3246        let local = entry.local.to_ascii_lowercase();
3247        if is_loopback_listener(&local) {
3248            summary.loopback_only += 1;
3249        } else if is_wildcard_listener(&local) {
3250            summary.wildcard_public += 1;
3251        } else {
3252            summary.specific_bind += 1;
3253        }
3254    }
3255    summary
3256}
3257
3258fn is_loopback_listener(local: &str) -> bool {
3259    local.starts_with("127.")
3260        || local.starts_with("[::1]")
3261        || local.starts_with("::1")
3262        || local.starts_with("localhost:")
3263}
3264
3265fn is_wildcard_listener(local: &str) -> bool {
3266    local.starts_with("0.0.0.0:")
3267        || local.starts_with("[::]:")
3268        || local.starts_with(":::")
3269        || local == "*:*"
3270}
3271
3272struct GitState {
3273    root: PathBuf,
3274    branch: String,
3275    dirty_entries: usize,
3276}
3277
3278impl GitState {
3279    fn status_label(&self) -> String {
3280        if self.dirty_entries == 0 {
3281            "clean".to_string()
3282        } else {
3283            format!("dirty ({} changed path(s))", self.dirty_entries)
3284        }
3285    }
3286}
3287
3288fn inspect_git_state(path: &Path) -> Option<GitState> {
3289    let root = capture_first_line(
3290        "git",
3291        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3292    )?;
3293    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3294        .unwrap_or_else(|| "detached".to_string());
3295    let output = Command::new("git")
3296        .args(["-C", path.to_str()?, "status", "--short"])
3297        .output()
3298        .ok()?;
3299    if !output.status.success() {
3300        return None;
3301    }
3302    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3303    Some(GitState {
3304        root: PathBuf::from(root),
3305        branch,
3306        dirty_entries,
3307    })
3308}
3309
3310struct HematiteState {
3311    docs_count: usize,
3312    import_count: usize,
3313    report_count: usize,
3314    workspace_profile: bool,
3315}
3316
3317fn collect_hematite_state(path: &Path) -> HematiteState {
3318    let root = path.join(".hematite");
3319    HematiteState {
3320        docs_count: count_entries_if_exists(&root.join("docs")),
3321        import_count: count_entries_if_exists(&root.join("imports")),
3322        report_count: count_entries_if_exists(&root.join("reports")),
3323        workspace_profile: root.join("workspace_profile.json").exists(),
3324    }
3325}
3326
3327fn count_entries_if_exists(path: &Path) -> usize {
3328    if !path.exists() || !path.is_dir() {
3329        return 0;
3330    }
3331    fs::read_dir(path)
3332        .ok()
3333        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3334        .unwrap_or(0)
3335}
3336
3337fn collect_project_markers(path: &Path) -> Vec<String> {
3338    [
3339        "Cargo.toml",
3340        "package.json",
3341        "pyproject.toml",
3342        "go.mod",
3343        "justfile",
3344        "Makefile",
3345        ".git",
3346    ]
3347    .iter()
3348    .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3349    .collect()
3350}
3351
3352struct ReleaseArtifactState {
3353    version: String,
3354    portable_dir: bool,
3355    portable_zip: bool,
3356    setup_exe: bool,
3357}
3358
3359fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3360    let cargo_toml = path.join("Cargo.toml");
3361    if !cargo_toml.exists() {
3362        return None;
3363    }
3364    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3365    let version = [regex_line_capture(
3366        &cargo_text,
3367        r#"(?m)^version\s*=\s*"([^"]+)""#,
3368    )?]
3369    .concat();
3370    let dist_windows = path.join("dist").join("windows");
3371    let prefix = format!("Hematite-{}", version);
3372    Some(ReleaseArtifactState {
3373        version,
3374        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3375        portable_zip: dist_windows
3376            .join(format!("{}-portable.zip", prefix))
3377            .exists(),
3378        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3379    })
3380}
3381
3382fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3383    let regex = regex::Regex::new(pattern).ok()?;
3384    let captures = regex.captures(text)?;
3385    captures.get(1).map(|m| m.as_str().to_string())
3386}
3387
3388fn bool_label(value: bool) -> &'static str {
3389    if value {
3390        "yes"
3391    } else {
3392        "no"
3393    }
3394}
3395
3396fn collect_toolchains() -> ToolchainReport {
3397    let config = crate::agent::config::load_config();
3398    let mut python_probes = Vec::new();
3399    let _ = if let Some(ref path) = config.python_path {
3400        python_probes.push(CommandProbe::new(path, &["--version"]));
3401    } else {
3402    };
3403
3404    python_probes.extend([
3405        CommandProbe::new("python3", &["--version"]),
3406        CommandProbe::new("python", &["--version"]),
3407        CommandProbe::new("py", &["-3", "--version"]),
3408        CommandProbe::new("py", &["--version"]),
3409    ]);
3410
3411    let checks = [
3412        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3413        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3414        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3415        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3416        ToolCheck::new(
3417            "npm",
3418            &[
3419                CommandProbe::new("npm", &["--version"]),
3420                CommandProbe::new("npm.cmd", &["--version"]),
3421            ],
3422        ),
3423        ToolCheck::new(
3424            "pnpm",
3425            &[
3426                CommandProbe::new("pnpm", &["--version"]),
3427                CommandProbe::new("pnpm.cmd", &["--version"]),
3428            ],
3429        ),
3430        ToolCheck::new("python", &python_probes),
3431        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3432        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3433        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3434        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3435    ];
3436
3437    let mut found = Vec::new();
3438    let mut missing = Vec::new();
3439
3440    for check in checks {
3441        match check.detect() {
3442            Some(version) => found.push((check.label.to_string(), version)),
3443            None => missing.push(check.label.to_string()),
3444        }
3445    }
3446
3447    ToolchainReport { found, missing }
3448}
3449
3450fn collect_package_managers() -> PackageManagerReport {
3451    let config = crate::agent::config::load_config();
3452    let mut pip_probes = Vec::new();
3453    if let Some(ref path) = config.python_path {
3454        pip_probes.push(CommandProbe::new(path, &["-m", "pip", "--version"]));
3455    }
3456    pip_probes.extend([
3457        CommandProbe::new("python3", &["-m", "pip", "--version"]),
3458        CommandProbe::new("python", &["-m", "pip", "--version"]),
3459        CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3460        CommandProbe::new("py", &["-m", "pip", "--version"]),
3461        CommandProbe::new("pip", &["--version"]),
3462    ]);
3463
3464    let checks = [
3465        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3466        ToolCheck::new(
3467            "npm",
3468            &[
3469                CommandProbe::new("npm", &["--version"]),
3470                CommandProbe::new("npm.cmd", &["--version"]),
3471            ],
3472        ),
3473        ToolCheck::new(
3474            "pnpm",
3475            &[
3476                CommandProbe::new("pnpm", &["--version"]),
3477                CommandProbe::new("pnpm.cmd", &["--version"]),
3478            ],
3479        ),
3480        ToolCheck::new("pip", &pip_probes),
3481        ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3482        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3483        ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3484        ToolCheck::new(
3485            "choco",
3486            &[
3487                CommandProbe::new("choco", &["--version"]),
3488                CommandProbe::new("choco.exe", &["--version"]),
3489            ],
3490        ),
3491        ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3492    ];
3493
3494    let mut found = Vec::new();
3495    for check in checks {
3496        match check.detect() {
3497            Some(version) => found.push((check.label.to_string(), version)),
3498            None => {}
3499        }
3500    }
3501
3502    PackageManagerReport { found }
3503}
3504
3505#[derive(Clone)]
3506struct ToolCheck {
3507    label: &'static str,
3508    probes: Vec<CommandProbe>,
3509}
3510
3511impl ToolCheck {
3512    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3513        Self {
3514            label,
3515            probes: probes.to_vec(),
3516        }
3517    }
3518
3519    fn detect(&self) -> Option<String> {
3520        for probe in &self.probes {
3521            if let Some(output) = capture_first_line(&probe.program, &probe.args) {
3522                return Some(output);
3523            }
3524        }
3525        None
3526    }
3527}
3528
3529#[derive(Clone)]
3530struct CommandProbe {
3531    program: String,
3532    args: Vec<String>,
3533}
3534
3535impl CommandProbe {
3536    fn new(program: &str, args: &[&str]) -> Self {
3537        Self {
3538            program: program.to_string(),
3539            args: args.iter().map(|s| s.to_string()).collect(),
3540        }
3541    }
3542}
3543
3544fn build_env_doctor_findings(
3545    toolchains: &ToolchainReport,
3546    package_managers: &PackageManagerReport,
3547    path_stats: &PathAnalysis,
3548) -> Vec<String> {
3549    let found_tools = toolchains
3550        .found
3551        .iter()
3552        .map(|(label, _)| label.as_str())
3553        .collect::<HashSet<_>>();
3554    let found_managers = package_managers
3555        .found
3556        .iter()
3557        .map(|(label, _)| label.as_str())
3558        .collect::<HashSet<_>>();
3559
3560    let mut findings = Vec::new();
3561
3562    if path_stats.duplicate_entries.len() > 0 {
3563        findings.push(format!(
3564            "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3565            path_stats.duplicate_entries.len()
3566        ));
3567    }
3568    if path_stats.missing_entries.len() > 0 {
3569        findings.push(format!(
3570            "PATH contains {} entries that do not exist on disk.",
3571            path_stats.missing_entries.len()
3572        ));
3573    }
3574    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3575        findings.push(
3576            "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3577                .to_string(),
3578        );
3579    }
3580    if found_tools.contains("node")
3581        && !found_managers.contains("npm")
3582        && !found_managers.contains("pnpm")
3583    {
3584        findings.push(
3585            "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3586                .to_string(),
3587        );
3588    }
3589    if found_tools.contains("python")
3590        && !found_managers.contains("pip")
3591        && !found_managers.contains("uv")
3592        && !found_managers.contains("pipx")
3593    {
3594        findings.push(
3595            "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3596                .to_string(),
3597        );
3598    }
3599    let windows_manager_count = ["winget", "choco", "scoop"]
3600        .iter()
3601        .filter(|label| found_managers.contains(**label))
3602        .count();
3603    if windows_manager_count > 1 {
3604        findings.push(
3605            "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3606                .to_string(),
3607        );
3608    }
3609    if findings.is_empty() && !found_managers.is_empty() {
3610        findings.push(
3611            "Core package-manager coverage looks healthy for a normal developer workstation."
3612                .to_string(),
3613        );
3614    }
3615
3616    findings
3617}
3618
3619fn capture_first_line<S: AsRef<str>>(program: &str, args: &[S]) -> Option<String> {
3620    let output = std::process::Command::new(program)
3621        .args(args.iter().map(|s| s.as_ref()))
3622        .output()
3623        .ok()?;
3624    if !output.status.success() {
3625        return None;
3626    }
3627
3628    let stdout = if output.stdout.is_empty() {
3629        String::from_utf8_lossy(&output.stderr).into_owned()
3630    } else {
3631        String::from_utf8_lossy(&output.stdout).into_owned()
3632    };
3633
3634    stdout
3635        .lines()
3636        .map(str::trim)
3637        .find(|line| !line.is_empty())
3638        .map(|line| line.to_string())
3639}
3640
3641fn human_bytes(bytes: u64) -> String {
3642    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3643    let mut value = bytes as f64;
3644    let mut unit_index = 0usize;
3645
3646    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3647        value /= 1024.0;
3648        unit_index += 1;
3649    }
3650
3651    if unit_index == 0 {
3652        format!("{} {}", bytes, UNITS[unit_index])
3653    } else {
3654        format!("{value:.1} {}", UNITS[unit_index])
3655    }
3656}
3657
3658#[cfg(target_os = "windows")]
3659fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3660    let mut adapters = Vec::new();
3661    let mut current: Option<NetworkAdapter> = None;
3662    let mut pending_dns = false;
3663
3664    for raw_line in text.lines() {
3665        let line = raw_line.trim_end();
3666        let trimmed = line.trim();
3667        if trimmed.is_empty() {
3668            pending_dns = false;
3669            continue;
3670        }
3671
3672        if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3673            if let Some(adapter) = current.take() {
3674                adapters.push(adapter);
3675            }
3676            current = Some(NetworkAdapter {
3677                name: trimmed.trim_end_matches(':').to_string(),
3678                ..NetworkAdapter::default()
3679            });
3680            pending_dns = false;
3681            continue;
3682        }
3683
3684        let Some(adapter) = current.as_mut() else {
3685            continue;
3686        };
3687
3688        if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3689            adapter.disconnected = true;
3690        }
3691
3692        if let Some(value) = value_after_colon(trimmed) {
3693            let normalized = normalize_ipconfig_value(value);
3694            if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3695                adapter.ipv4.push(normalized);
3696                pending_dns = false;
3697            } else if trimmed.starts_with("IPv6 Address")
3698                || trimmed.starts_with("Temporary IPv6 Address")
3699                || trimmed.starts_with("Link-local IPv6 Address")
3700            {
3701                if !normalized.is_empty() {
3702                    adapter.ipv6.push(normalized);
3703                }
3704                pending_dns = false;
3705            } else if trimmed.starts_with("Default Gateway") {
3706                if !normalized.is_empty() {
3707                    adapter.gateways.push(normalized);
3708                }
3709                pending_dns = false;
3710            } else if trimmed.starts_with("DNS Servers") {
3711                if !normalized.is_empty() {
3712                    adapter.dns_servers.push(normalized);
3713                }
3714                pending_dns = true;
3715            } else {
3716                pending_dns = false;
3717            }
3718        } else if pending_dns {
3719            let normalized = normalize_ipconfig_value(trimmed);
3720            if !normalized.is_empty() {
3721                adapter.dns_servers.push(normalized);
3722            }
3723        }
3724    }
3725
3726    if let Some(adapter) = current.take() {
3727        adapters.push(adapter);
3728    }
3729
3730    for adapter in &mut adapters {
3731        dedup_vec(&mut adapter.ipv4);
3732        dedup_vec(&mut adapter.ipv6);
3733        dedup_vec(&mut adapter.gateways);
3734        dedup_vec(&mut adapter.dns_servers);
3735    }
3736
3737    adapters
3738}
3739
3740#[cfg(not(target_os = "windows"))]
3741fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3742    let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3743
3744    for line in text.lines() {
3745        let cols: Vec<&str> = line.split_whitespace().collect();
3746        if cols.len() < 4 {
3747            continue;
3748        }
3749        let name = cols[1].trim_end_matches(':').to_string();
3750        let family = cols[2];
3751        let addr = cols[3].split('/').next().unwrap_or("").to_string();
3752        let entry = adapters
3753            .entry(name.clone())
3754            .or_insert_with(|| NetworkAdapter {
3755                name,
3756                ..NetworkAdapter::default()
3757            });
3758        match family {
3759            "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3760            "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3761            _ => {}
3762        }
3763    }
3764
3765    adapters.into_values().collect()
3766}
3767
3768#[cfg(not(target_os = "windows"))]
3769fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3770    for line in text.lines() {
3771        let cols: Vec<&str> = line.split_whitespace().collect();
3772        if cols.len() < 5 {
3773            continue;
3774        }
3775        let gateway = cols
3776            .windows(2)
3777            .find(|pair| pair[0] == "via")
3778            .map(|pair| pair[1].to_string());
3779        let dev = cols
3780            .windows(2)
3781            .find(|pair| pair[0] == "dev")
3782            .map(|pair| pair[1]);
3783        if let (Some(gateway), Some(dev)) = (gateway, dev) {
3784            if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3785                adapter.gateways.push(gateway);
3786            }
3787        }
3788    }
3789
3790    for adapter in adapters {
3791        dedup_vec(&mut adapter.gateways);
3792    }
3793}
3794
3795#[cfg(not(target_os = "windows"))]
3796fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3797    let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3798        return;
3799    };
3800    let mut dns_servers = text
3801        .lines()
3802        .filter_map(|line| line.strip_prefix("nameserver "))
3803        .map(str::trim)
3804        .filter(|value| !value.is_empty())
3805        .map(|value| value.to_string())
3806        .collect::<Vec<_>>();
3807    dedup_vec(&mut dns_servers);
3808    if dns_servers.is_empty() {
3809        return;
3810    }
3811    for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3812        adapter.dns_servers = dns_servers.clone();
3813    }
3814}
3815
3816#[cfg(target_os = "windows")]
3817fn value_after_colon(line: &str) -> Option<&str> {
3818    line.split_once(':').map(|(_, value)| value.trim())
3819}
3820
3821#[cfg(target_os = "windows")]
3822fn normalize_ipconfig_value(value: &str) -> String {
3823    value
3824        .trim()
3825        .trim_end_matches("(Preferred)")
3826        .trim_end_matches("(Deprecated)")
3827        .trim()
3828        .trim_matches(['(', ')'])
3829        .trim()
3830        .to_string()
3831}
3832
3833#[cfg(target_os = "windows")]
3834fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3835    let mac_upper = mac.to_ascii_uppercase();
3836    if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3837        return true;
3838    }
3839
3840    ip == "255.255.255.255"
3841        || ip.starts_with("224.")
3842        || ip.starts_with("225.")
3843        || ip.starts_with("226.")
3844        || ip.starts_with("227.")
3845        || ip.starts_with("228.")
3846        || ip.starts_with("229.")
3847        || ip.starts_with("230.")
3848        || ip.starts_with("231.")
3849        || ip.starts_with("232.")
3850        || ip.starts_with("233.")
3851        || ip.starts_with("234.")
3852        || ip.starts_with("235.")
3853        || ip.starts_with("236.")
3854        || ip.starts_with("237.")
3855        || ip.starts_with("238.")
3856        || ip.starts_with("239.")
3857}
3858
3859fn dedup_vec(values: &mut Vec<String>) {
3860    let mut seen = HashSet::new();
3861    values.retain(|value| seen.insert(value.clone()));
3862}
3863
3864#[cfg(target_os = "windows")]
3865fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3866    let trimmed = text.trim();
3867    if trimmed.is_empty() {
3868        return Vec::new();
3869    }
3870
3871    let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3872        return Vec::new();
3873    };
3874    let entries = match value {
3875        Value::Array(items) => items,
3876        other => vec![other],
3877    };
3878
3879    let mut neighbors = Vec::new();
3880    for entry in entries {
3881        let ip = entry
3882            .get("IPAddress")
3883            .and_then(|v| v.as_str())
3884            .unwrap_or("")
3885            .to_string();
3886        if ip.is_empty() {
3887            continue;
3888        }
3889        let mac = entry
3890            .get("LinkLayerAddress")
3891            .and_then(|v| v.as_str())
3892            .unwrap_or("unknown")
3893            .to_string();
3894        let state = entry
3895            .get("State")
3896            .and_then(|v| v.as_str())
3897            .unwrap_or("unknown")
3898            .to_string();
3899        let iface = entry
3900            .get("InterfaceAlias")
3901            .and_then(|v| v.as_str())
3902            .unwrap_or("unknown")
3903            .to_string();
3904        if is_noise_lan_neighbor(&ip, &mac) {
3905            continue;
3906        }
3907        neighbors.push((ip, mac, state, iface));
3908    }
3909
3910    neighbors
3911}
3912
3913#[cfg(target_os = "windows")]
3914fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3915    let trimmed = text.trim();
3916    if trimmed.is_empty() {
3917        return Ok(Vec::new());
3918    }
3919
3920    let value: Value = serde_json::from_str(trimmed)
3921        .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3922    let entries = match value {
3923        Value::Array(items) => items,
3924        other => vec![other],
3925    };
3926
3927    let mut services = Vec::new();
3928    for entry in entries {
3929        let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3930            continue;
3931        };
3932        services.push(ServiceEntry {
3933            name: name.to_string(),
3934            status: entry
3935                .get("State")
3936                .and_then(|v| v.as_str())
3937                .unwrap_or("unknown")
3938                .to_string(),
3939            startup: entry
3940                .get("StartMode")
3941                .and_then(|v| v.as_str())
3942                .map(|v| v.to_string()),
3943            display_name: entry
3944                .get("DisplayName")
3945                .and_then(|v| v.as_str())
3946                .map(|v| v.to_string()),
3947            start_name: entry
3948                .get("StartName")
3949                .and_then(|v| v.as_str())
3950                .map(|v| v.to_string()),
3951        });
3952    }
3953
3954    Ok(services)
3955}
3956
3957#[cfg(target_os = "windows")]
3958fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3959    match node.cloned() {
3960        Some(Value::Array(items)) => items,
3961        Some(other) => vec![other],
3962        None => Vec::new(),
3963    }
3964}
3965
3966#[cfg(target_os = "windows")]
3967fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3968    windows_json_entries(node)
3969        .into_iter()
3970        .filter_map(|entry| {
3971            let name = entry
3972                .get("FriendlyName")
3973                .and_then(|v| v.as_str())
3974                .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3975                .unwrap_or("")
3976                .trim()
3977                .to_string();
3978            if name.is_empty() {
3979                return None;
3980            }
3981            Some(WindowsPnpDevice {
3982                name,
3983                status: entry
3984                    .get("Status")
3985                    .and_then(|v| v.as_str())
3986                    .unwrap_or("Unknown")
3987                    .trim()
3988                    .to_string(),
3989                problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3990                    entry
3991                        .get("Problem")
3992                        .and_then(|v| v.as_i64())
3993                        .map(|v| v as u64)
3994                }),
3995                class_name: entry
3996                    .get("Class")
3997                    .and_then(|v| v.as_str())
3998                    .map(|v| v.trim().to_string()),
3999                instance_id: entry
4000                    .get("InstanceId")
4001                    .and_then(|v| v.as_str())
4002                    .map(|v| v.trim().to_string()),
4003            })
4004        })
4005        .collect()
4006}
4007
4008#[cfg(target_os = "windows")]
4009fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
4010    windows_json_entries(node)
4011        .into_iter()
4012        .filter_map(|entry| {
4013            let name = entry
4014                .get("Name")
4015                .and_then(|v| v.as_str())
4016                .unwrap_or("")
4017                .trim()
4018                .to_string();
4019            if name.is_empty() {
4020                return None;
4021            }
4022            Some(WindowsSoundDevice {
4023                name,
4024                status: entry
4025                    .get("Status")
4026                    .and_then(|v| v.as_str())
4027                    .unwrap_or("Unknown")
4028                    .trim()
4029                    .to_string(),
4030                manufacturer: entry
4031                    .get("Manufacturer")
4032                    .and_then(|v| v.as_str())
4033                    .map(|v| v.trim().to_string()),
4034            })
4035        })
4036        .collect()
4037}
4038
4039#[cfg(target_os = "windows")]
4040fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
4041    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4042        || device.problem.unwrap_or(0) != 0
4043}
4044
4045#[cfg(target_os = "windows")]
4046fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4047    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4048}
4049
4050#[cfg(target_os = "windows")]
4051fn is_microphone_like_name(name: &str) -> bool {
4052    let lower = name.to_ascii_lowercase();
4053    lower.contains("microphone")
4054        || lower.contains("mic")
4055        || lower.contains("input")
4056        || lower.contains("array")
4057        || lower.contains("capture")
4058        || lower.contains("record")
4059}
4060
4061#[cfg(target_os = "windows")]
4062fn is_bluetooth_like_name(name: &str) -> bool {
4063    let lower = name.to_ascii_lowercase();
4064    lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4065}
4066
4067#[cfg(target_os = "windows")]
4068fn service_is_running(service: &ServiceEntry) -> bool {
4069    service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4070}
4071
4072#[cfg(not(target_os = "windows"))]
4073fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4074    let mut startup_modes = std::collections::HashMap::<String, String>::new();
4075    for line in startup_text.lines() {
4076        let cols: Vec<&str> = line.split_whitespace().collect();
4077        if cols.len() < 2 {
4078            continue;
4079        }
4080        startup_modes.insert(cols[0].to_string(), cols[1].to_string());
4081    }
4082
4083    let mut services = Vec::new();
4084    for line in status_text.lines() {
4085        let cols: Vec<&str> = line.split_whitespace().collect();
4086        if cols.len() < 4 {
4087            continue;
4088        }
4089        let unit = cols[0];
4090        let load = cols[1];
4091        let active = cols[2];
4092        let sub = cols[3];
4093        let description = if cols.len() > 4 {
4094            Some(cols[4..].join(" "))
4095        } else {
4096            None
4097        };
4098        services.push(ServiceEntry {
4099            name: unit.to_string(),
4100            status: format!("{}/{}", active, sub),
4101            startup: startup_modes
4102                .get(unit)
4103                .cloned()
4104                .or_else(|| Some(load.to_string())),
4105            display_name: description,
4106            start_name: None,
4107        });
4108    }
4109
4110    services
4111}
4112
4113// ── health_report ─────────────────────────────────────────────────────────────
4114
4115/// Synthesized system health report — runs multiple checks and returns a
4116/// plain-English tiered verdict suitable for both developers and non-technical
4117/// users who just want to know if their machine is okay.
4118fn inspect_health_report() -> Result<String, String> {
4119    let mut needs_fix: Vec<String> = Vec::new();
4120    let mut watch: Vec<String> = Vec::new();
4121    let mut good: Vec<String> = Vec::new();
4122    let mut tips: Vec<String> = Vec::new();
4123
4124    health_check_disk(&mut needs_fix, &mut watch, &mut good);
4125    health_check_memory(&mut watch, &mut good);
4126    health_check_network(&mut needs_fix, &mut watch, &mut good);
4127    health_check_pending_reboot(&mut watch, &mut good);
4128    health_check_services(&mut needs_fix, &mut watch, &mut good);
4129    health_check_thermal(&mut watch, &mut good);
4130    health_check_tools(&mut watch, &mut good, &mut tips);
4131    health_check_recent_errors(&mut watch, &mut tips);
4132
4133    let overall = if !needs_fix.is_empty() {
4134        "ACTION REQUIRED"
4135    } else if !watch.is_empty() {
4136        "WORTH A LOOK"
4137    } else {
4138        "ALL GOOD"
4139    };
4140
4141    let mut out = format!("System Health Report — {overall}\n\n");
4142
4143    if !needs_fix.is_empty() {
4144        out.push_str("Needs fixing:\n");
4145        for item in &needs_fix {
4146            out.push_str(&format!("  [!] {item}\n"));
4147        }
4148        out.push('\n');
4149    }
4150    if !watch.is_empty() {
4151        out.push_str("Worth watching:\n");
4152        for item in &watch {
4153            out.push_str(&format!("  [-] {item}\n"));
4154        }
4155        out.push('\n');
4156    }
4157    if !good.is_empty() {
4158        out.push_str("Looking good:\n");
4159        for item in &good {
4160            out.push_str(&format!("  [+] {item}\n"));
4161        }
4162        out.push('\n');
4163    }
4164    if !tips.is_empty() {
4165        out.push_str("To dig deeper:\n");
4166        for tip in &tips {
4167            out.push_str(&format!("  {tip}\n"));
4168        }
4169    }
4170
4171    Ok(out.trim_end().to_string())
4172}
4173
4174fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4175    #[cfg(target_os = "windows")]
4176    {
4177        let script = r#"try {
4178    $d = Get-PSDrive C -ErrorAction Stop
4179    "$($d.Free)|$($d.Used)"
4180} catch { "ERR" }"#;
4181        if let Ok(out) = Command::new("powershell")
4182            .args(["-NoProfile", "-Command", script])
4183            .output()
4184        {
4185            let text = String::from_utf8_lossy(&out.stdout);
4186            let text = text.trim();
4187            if !text.starts_with("ERR") {
4188                let parts: Vec<&str> = text.split('|').collect();
4189                if parts.len() == 2 {
4190                    let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
4191                    let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
4192                    let total = free_bytes + used_bytes;
4193                    let free_gb = free_bytes / 1_073_741_824;
4194                    let pct_free = if total > 0 {
4195                        (free_bytes as f64 / total as f64 * 100.0) as u64
4196                    } else {
4197                        0
4198                    };
4199                    let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4200                    if free_gb < 5 {
4201                        needs_fix.push(format!(
4202                            "{msg} — very low. Free up space or your system may slow down or stop working."
4203                        ));
4204                    } else if free_gb < 15 {
4205                        watch.push(format!("{msg} — getting low, consider cleaning up."));
4206                    } else {
4207                        good.push(msg);
4208                    }
4209                    return;
4210                }
4211            }
4212        }
4213        watch.push("Disk: could not read free space from C: drive.".to_string());
4214    }
4215
4216    #[cfg(not(target_os = "windows"))]
4217    {
4218        if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4219            let text = String::from_utf8_lossy(&out.stdout);
4220            for line in text.lines().skip(1) {
4221                let cols: Vec<&str> = line.split_whitespace().collect();
4222                if cols.len() >= 5 {
4223                    let avail_str = cols[3].trim_end_matches('G');
4224                    let use_pct = cols[4].trim_end_matches('%');
4225                    let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4226                    let used_pct: u64 = use_pct.parse().unwrap_or(0);
4227                    let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4228                    if avail_gb < 5 {
4229                        needs_fix.push(format!(
4230                            "{msg} — very low. Free up space to prevent system issues."
4231                        ));
4232                    } else if avail_gb < 15 {
4233                        watch.push(format!("{msg} — getting low."));
4234                    } else {
4235                        good.push(msg);
4236                    }
4237                    return;
4238                }
4239            }
4240        }
4241        watch.push("Disk: could not determine free space.".to_string());
4242    }
4243}
4244
4245fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4246    #[cfg(target_os = "windows")]
4247    {
4248        let script = r#"try {
4249    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4250    "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4251} catch { "ERR" }"#;
4252        if let Ok(out) = Command::new("powershell")
4253            .args(["-NoProfile", "-Command", script])
4254            .output()
4255        {
4256            let text = String::from_utf8_lossy(&out.stdout);
4257            let text = text.trim();
4258            if !text.starts_with("ERR") {
4259                let parts: Vec<&str> = text.split('|').collect();
4260                if parts.len() == 2 {
4261                    let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4262                    let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4263                    if total_kb > 0 {
4264                        let free_gb = free_kb / 1_048_576;
4265                        let total_gb = total_kb / 1_048_576;
4266                        let free_pct = free_kb * 100 / total_kb;
4267                        let msg = format!(
4268                            "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4269                        );
4270                        if free_pct < 10 {
4271                            watch.push(format!(
4272                                "{msg} — very low. Close unused apps to free up memory."
4273                            ));
4274                        } else if free_pct < 25 {
4275                            watch.push(format!("{msg} — running a bit low."));
4276                        } else {
4277                            good.push(msg);
4278                        }
4279                        return;
4280                    }
4281                }
4282            }
4283        }
4284    }
4285
4286    #[cfg(not(target_os = "windows"))]
4287    {
4288        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4289            let mut total_kb = 0u64;
4290            let mut avail_kb = 0u64;
4291            for line in content.lines() {
4292                if line.starts_with("MemTotal:") {
4293                    total_kb = line
4294                        .split_whitespace()
4295                        .nth(1)
4296                        .and_then(|v| v.parse().ok())
4297                        .unwrap_or(0);
4298                } else if line.starts_with("MemAvailable:") {
4299                    avail_kb = line
4300                        .split_whitespace()
4301                        .nth(1)
4302                        .and_then(|v| v.parse().ok())
4303                        .unwrap_or(0);
4304                }
4305            }
4306            if total_kb > 0 {
4307                let free_gb = avail_kb / 1_048_576;
4308                let total_gb = total_kb / 1_048_576;
4309                let free_pct = avail_kb * 100 / total_kb;
4310                let msg =
4311                    format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4312                if free_pct < 10 {
4313                    watch.push(format!("{msg} — very low. Close unused apps."));
4314                } else if free_pct < 25 {
4315                    watch.push(format!("{msg} — running a bit low."));
4316                } else {
4317                    good.push(msg);
4318                }
4319            }
4320        }
4321    }
4322}
4323
4324/// Try running `cmd --arg` via PATH first, then via a known install-path fallback.
4325/// Prevents false "not installed" reports when the process PATH omits tool directories
4326/// (e.g. ~/.cargo/bin missing from a shortcut-launched or headless session).
4327fn probe_tool(cmd: &str, arg: &str) -> bool {
4328    if Command::new(cmd)
4329        .arg(arg)
4330        .stdout(std::process::Stdio::null())
4331        .stderr(std::process::Stdio::null())
4332        .status()
4333        .map(|s| s.success())
4334        .unwrap_or(false)
4335    {
4336        return true;
4337    }
4338    // Fallback: well-known Windows install locations for tools that live outside system32.
4339    #[cfg(windows)]
4340    {
4341        let home = std::env::var("USERPROFILE").unwrap_or_default();
4342        let fallback: Option<String> = match cmd {
4343            "cargo" | "rustc" | "rustup" => Some(format!(r"{}\\.cargo\\bin\\{}.exe", home, cmd)),
4344            "node" => Some(r"C:\Program Files\nodejs\node.exe".to_string()),
4345            "npm" => Some("C:\\Program Files\\nodejs\\npm.cmd".to_string()),
4346            _ => None,
4347        };
4348        if let Some(path) = fallback {
4349            return Command::new(&path)
4350                .arg(arg)
4351                .stdout(std::process::Stdio::null())
4352                .stderr(std::process::Stdio::null())
4353                .status()
4354                .map(|s| s.success())
4355                .unwrap_or(false);
4356        }
4357    }
4358    false
4359}
4360
4361fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4362    let tool_checks: &[(&str, &str, &str)] = &[
4363        ("git", "--version", "Git"),
4364        ("cargo", "--version", "Rust / Cargo"),
4365        ("node", "--version", "Node.js"),
4366        ("python", "--version", "Python"),
4367        ("python3", "--version", "Python 3"),
4368        ("npm", "--version", "npm"),
4369    ];
4370
4371    let mut found: Vec<String> = Vec::new();
4372    let mut missing: Vec<String> = Vec::new();
4373    let mut python_found = false;
4374
4375    for (cmd, arg, label) in tool_checks {
4376        if cmd.starts_with("python") && python_found {
4377            continue;
4378        }
4379        let ok = probe_tool(cmd, arg);
4380        if ok {
4381            found.push((*label).to_string());
4382            if cmd.starts_with("python") {
4383                python_found = true;
4384            }
4385        } else if !cmd.starts_with("python") || !python_found {
4386            missing.push((*label).to_string());
4387        }
4388    }
4389
4390    if !found.is_empty() {
4391        good.push(format!("Dev tools found: {}", found.join(", ")));
4392    }
4393    if !missing.is_empty() {
4394        watch.push(format!(
4395            "Not installed (or not on PATH): {} — only matters if you need them",
4396            missing.join(", ")
4397        ));
4398        tips.push(
4399            "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4400                .to_string(),
4401        );
4402    }
4403}
4404
4405fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4406    #[cfg(target_os = "windows")]
4407    {
4408        let script = r#"try {
4409    $cutoff = (Get-Date).AddHours(-24)
4410    $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4411    $count
4412} catch { "0" }"#;
4413        if let Ok(out) = Command::new("powershell")
4414            .args(["-NoProfile", "-Command", script])
4415            .output()
4416        {
4417            let text = String::from_utf8_lossy(&out.stdout);
4418            let count: u64 = text.trim().parse().unwrap_or(0);
4419            if count > 0 {
4420                watch.push(format!(
4421                    "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4422                    if count == 1 { "" } else { "s" }
4423                ));
4424                tips.push(
4425                    "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4426                        .to_string(),
4427                );
4428            }
4429        }
4430    }
4431
4432    #[cfg(not(target_os = "windows"))]
4433    {
4434        if let Ok(out) = Command::new("journalctl")
4435            .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4436            .output()
4437        {
4438            let text = String::from_utf8_lossy(&out.stdout);
4439            if !text.trim().is_empty() {
4440                watch.push("Critical/error entries found in the system journal.".to_string());
4441                tips.push(
4442                    "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4443                );
4444            }
4445        }
4446    }
4447}
4448
4449fn health_check_network(
4450    needs_fix: &mut Vec<String>,
4451    watch: &mut Vec<String>,
4452    good: &mut Vec<String>,
4453) {
4454    #[cfg(target_os = "windows")]
4455    {
4456        // Use .NET Ping directly — PS5.1 compatible, 2-second timeout.
4457        let script = r#"try {
4458    $ping = New-Object System.Net.NetworkInformation.Ping
4459    $r = $ping.Send("1.1.1.1", 2000)
4460    if ($r.Status -eq 'Success') { "OK|$($r.RoundtripTime)" } else { "FAIL" }
4461} catch { "FAIL" }"#;
4462        if let Ok(out) = Command::new("powershell")
4463            .args(["-NoProfile", "-Command", script])
4464            .output()
4465        {
4466            let text = String::from_utf8_lossy(&out.stdout);
4467            let text = text.trim();
4468            if text.starts_with("OK") {
4469                let latency = text.split('|').nth(1).unwrap_or("?");
4470                let latency_ms: u64 = latency.parse().unwrap_or(0);
4471                let msg = format!("Internet connectivity: reachable ({}ms RTT)", latency_ms);
4472                if latency_ms > 300 {
4473                    watch.push(format!("{msg} — high latency, may indicate network issue."));
4474                } else {
4475                    good.push(msg);
4476                }
4477            } else {
4478                needs_fix.push(
4479                    "Internet connectivity: unreachable — could not ping 1.1.1.1. \
4480                     Check adapter, gateway, or DNS."
4481                        .to_string(),
4482                );
4483            }
4484            return;
4485        }
4486        watch.push("Network: could not run connectivity check.".to_string());
4487    }
4488
4489    #[cfg(not(target_os = "windows"))]
4490    {
4491        let _ = watch;
4492        let ok = Command::new("ping")
4493            .args(["-c", "1", "-W", "2", "1.1.1.1"])
4494            .stdout(std::process::Stdio::null())
4495            .stderr(std::process::Stdio::null())
4496            .status()
4497            .map(|s| s.success())
4498            .unwrap_or(false);
4499        if ok {
4500            good.push("Internet connectivity: reachable.".to_string());
4501        } else {
4502            needs_fix.push("Internet connectivity: unreachable — cannot ping 1.1.1.1.".to_string());
4503        }
4504    }
4505}
4506
4507fn health_check_pending_reboot(watch: &mut Vec<String>, good: &mut Vec<String>) {
4508    #[cfg(target_os = "windows")]
4509    {
4510        let script = r#"try {
4511    $pending = $false
4512    $reasons = @()
4513    if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' -ErrorAction SilentlyContinue) {
4514        $pending = $true; $reasons += 'CBS/component update'
4515    }
4516    if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -ErrorAction SilentlyContinue) {
4517        $pending = $true; $reasons += 'Windows Update'
4518    }
4519    $pfr = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
4520    if ($pfr -and $pfr.PendingFileRenameOperations) {
4521        $pending = $true; $reasons += 'file rename ops'
4522    }
4523    if ($pending) { "PENDING|$($reasons -join ',')" } else { "OK" }
4524} catch { "OK" }"#;
4525        if let Ok(out) = Command::new("powershell")
4526            .args(["-NoProfile", "-Command", script])
4527            .output()
4528        {
4529            let text = String::from_utf8_lossy(&out.stdout);
4530            let text = text.trim();
4531            if text.starts_with("PENDING") {
4532                let reasons = text.split('|').nth(1).unwrap_or("unknown reason");
4533                watch.push(format!(
4534                    "Pending reboot required ({reasons}) — restart when convenient to apply changes."
4535                ));
4536            } else {
4537                good.push("No pending reboot.".to_string());
4538            }
4539        }
4540    }
4541
4542    #[cfg(not(target_os = "windows"))]
4543    {
4544        // Linux: check if a kernel update is pending (requires reboot to take effect)
4545        if std::path::Path::new("/var/run/reboot-required").exists() {
4546            watch.push(
4547                "Pending reboot required — a kernel or package update needs a restart.".to_string(),
4548            );
4549        } else {
4550            good.push("No pending reboot.".to_string());
4551        }
4552    }
4553}
4554
4555fn health_check_services(
4556    needs_fix: &mut Vec<String>,
4557    watch: &mut Vec<String>,
4558    good: &mut Vec<String>,
4559) {
4560    #[cfg(not(target_os = "windows"))]
4561    let _ = (&needs_fix, &good);
4562    #[cfg(target_os = "windows")]
4563    let _ = &watch;
4564
4565    #[cfg(target_os = "windows")]
4566    {
4567        // Only checks services whose being stopped indicates a real system problem.
4568        let script = r#"try {
4569    $names = @('EventLog','WinDefend','Dnscache')
4570    $stopped = @()
4571    foreach ($n in $names) {
4572        $s = Get-Service $n -ErrorAction SilentlyContinue
4573        if ($s -and $s.Status -ne 'Running') { $stopped += $n }
4574    }
4575    if ($stopped.Count -gt 0) { "STOPPED|$($stopped -join ',')" } else { "OK" }
4576} catch { "OK" }"#;
4577        if let Ok(out) = Command::new("powershell")
4578            .args(["-NoProfile", "-Command", script])
4579            .output()
4580        {
4581            let text = String::from_utf8_lossy(&out.stdout);
4582            let text = text.trim();
4583            if text.starts_with("STOPPED") {
4584                let names = text.split('|').nth(1).unwrap_or("unknown");
4585                needs_fix.push(format!(
4586                    "Critical service(s) not running: {names} — these should always be active."
4587                ));
4588            } else {
4589                good.push("Core services (Event Log, Defender, DNS) all running.".to_string());
4590            }
4591        }
4592    }
4593
4594    #[cfg(not(target_os = "windows"))]
4595    {
4596        // Linux: check systemd failed units
4597        if let Ok(out) = Command::new("systemctl")
4598            .args(["--failed", "--no-legend", "--plain"])
4599            .output()
4600        {
4601            let text = String::from_utf8_lossy(&out.stdout);
4602            let failed: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4603            if !failed.is_empty() {
4604                watch.push(format!(
4605                    "{} failed systemd unit(s): {}",
4606                    failed.len(),
4607                    failed.join(", ")
4608                ));
4609            } else {
4610                good.push("No failed systemd units.".to_string());
4611            }
4612        }
4613    }
4614}
4615
4616fn health_check_thermal(watch: &mut Vec<String>, good: &mut Vec<String>) {
4617    #[cfg(target_os = "windows")]
4618    {
4619        // WMI thermal zones — best-effort, silently skip if unavailable or requires elevation.
4620        let script = r#"try {
4621    $zones = Get-WmiObject -Namespace "root/wmi" -Class MSAcpi_ThermalZoneTemperature -ErrorAction Stop
4622    $temps = $zones | ForEach-Object { [math]::Round(($_.CurrentTemperature - 2732) / 10, 1) }
4623    $max = ($temps | Measure-Object -Maximum).Maximum
4624    "$max"
4625} catch { "NA" }"#;
4626        if let Ok(out) = Command::new("powershell")
4627            .args(["-NoProfile", "-Command", script])
4628            .output()
4629        {
4630            let text = String::from_utf8_lossy(&out.stdout);
4631            let text = text.trim();
4632            if text != "NA" && !text.is_empty() {
4633                if let Ok(temp) = text.parse::<f64>() {
4634                    let msg = format!("CPU thermal: {temp:.0}°C peak zone temperature");
4635                    if temp >= 90.0 {
4636                        watch.push(format!("{msg} — very high, check cooling and airflow."));
4637                    } else if temp >= 75.0 {
4638                        watch.push(format!(
4639                            "{msg} — elevated under load, monitor for throttling."
4640                        ));
4641                    } else {
4642                        good.push(format!("{msg} — normal."));
4643                    }
4644                }
4645            }
4646            // If NA or unparseable, skip silently — thermal WMI often needs admin.
4647        }
4648    }
4649
4650    #[cfg(not(target_os = "windows"))]
4651    {
4652        // Linux: read first available hwmon temp input
4653        let paths = [
4654            "/sys/class/thermal/thermal_zone0/temp",
4655            "/sys/class/hwmon/hwmon0/temp1_input",
4656        ];
4657        for path in &paths {
4658            if let Ok(content) = std::fs::read_to_string(path) {
4659                if let Ok(raw) = content.trim().parse::<u64>() {
4660                    let temp_c = raw / 1000;
4661                    let msg = format!("CPU thermal: {temp_c}°C");
4662                    if temp_c >= 90 {
4663                        watch.push(format!("{msg} — very high, check cooling."));
4664                    } else if temp_c >= 75 {
4665                        watch.push(format!("{msg} — elevated under load."));
4666                    } else {
4667                        good.push(format!("{msg} — normal."));
4668                    }
4669                    return;
4670                }
4671            }
4672        }
4673    }
4674}
4675
4676// ── log_check ─────────────────────────────────────────────────────────────────
4677
4678fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4679    let mut out = String::from("Host inspection: log_check\n\n");
4680
4681    #[cfg(target_os = "windows")]
4682    {
4683        // Pull recent critical/error events from Windows Application and System logs.
4684        let hours = lookback_hours.unwrap_or(24);
4685        out.push_str(&format!(
4686            "Checking System/Application logs from the last {} hours...\n\n",
4687            hours
4688        ));
4689
4690        let n = max_entries.clamp(1, 50);
4691        let script = format!(
4692            r#"try {{
4693    $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4694    if (-not $events) {{ "NO_EVENTS"; exit }}
4695    $events | Select-Object -First {n} | ForEach-Object {{
4696        $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4697        $line
4698    }}
4699}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4700            hours = hours,
4701            n = n
4702        );
4703        let output = Command::new("powershell")
4704            .args(["-NoProfile", "-Command", &script])
4705            .output()
4706            .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4707
4708        let raw = String::from_utf8_lossy(&output.stdout);
4709        let text = raw.trim();
4710
4711        if text.is_empty() || text == "NO_EVENTS" {
4712            out.push_str("No critical or error events found in Application/System logs.\n");
4713            return Ok(out.trim_end().to_string());
4714        }
4715        if text.starts_with("ERROR:") {
4716            out.push_str(&format!("Warning: event log query returned: {text}\n"));
4717            return Ok(out.trim_end().to_string());
4718        }
4719
4720        let mut count = 0usize;
4721        for line in text.lines() {
4722            let parts: Vec<&str> = line.splitn(4, '|').collect();
4723            if parts.len() == 4 {
4724                let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4725                out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4726                count += 1;
4727            }
4728        }
4729        out.push_str(&format!(
4730            "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4731        ));
4732    }
4733
4734    #[cfg(not(target_os = "windows"))]
4735    {
4736        let _ = lookback_hours;
4737        // Use journalctl on Linux/macOS if available.
4738        let n = max_entries.clamp(1, 50).to_string();
4739        let output = Command::new("journalctl")
4740            .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4741            .output();
4742
4743        match output {
4744            Ok(o) if o.status.success() => {
4745                let text = String::from_utf8_lossy(&o.stdout);
4746                let trimmed = text.trim();
4747                if trimmed.is_empty() || trimmed.contains("No entries") {
4748                    out.push_str("No critical or error entries found in the system journal.\n");
4749                } else {
4750                    out.push_str(trimmed);
4751                    out.push('\n');
4752                    out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4753                }
4754            }
4755            _ => {
4756                // Fallback: check /var/log/syslog or /var/log/messages
4757                let log_paths = ["/var/log/syslog", "/var/log/messages"];
4758                let mut found = false;
4759                for log_path in &log_paths {
4760                    if let Ok(content) = std::fs::read_to_string(log_path) {
4761                        let lines: Vec<&str> = content.lines().collect();
4762                        let tail: Vec<&str> = lines
4763                            .iter()
4764                            .rev()
4765                            .filter(|l| {
4766                                let l_lower = l.to_ascii_lowercase();
4767                                l_lower.contains("error") || l_lower.contains("crit")
4768                            })
4769                            .take(max_entries)
4770                            .copied()
4771                            .collect::<Vec<_>>()
4772                            .into_iter()
4773                            .rev()
4774                            .collect();
4775                        if !tail.is_empty() {
4776                            out.push_str(&format!("Source: {log_path}\n"));
4777                            for l in &tail {
4778                                out.push_str(l);
4779                                out.push('\n');
4780                            }
4781                            found = true;
4782                            break;
4783                        }
4784                    }
4785                }
4786                if !found {
4787                    out.push_str(
4788                        "journalctl not found and no readable syslog detected on this system.\n",
4789                    );
4790                }
4791            }
4792        }
4793    }
4794
4795    Ok(out.trim_end().to_string())
4796}
4797
4798// ── startup_items ─────────────────────────────────────────────────────────────
4799
4800fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4801    let mut out = String::from("Host inspection: startup_items\n\n");
4802
4803    #[cfg(target_os = "windows")]
4804    {
4805        // Query both HKLM and HKCU Run keys.
4806        let script = r#"
4807$hives = @(
4808    @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4809    @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4810    @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4811)
4812foreach ($h in $hives) {
4813    try {
4814        $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4815        $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4816            "$($h.Hive)|$($_.Name)|$($_.Value)"
4817        }
4818    } catch {}
4819}
4820"#;
4821        let output = Command::new("powershell")
4822            .args(["-NoProfile", "-Command", script])
4823            .output()
4824            .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4825
4826        let raw = String::from_utf8_lossy(&output.stdout);
4827        let text = raw.trim();
4828
4829        let entries: Vec<(String, String, String)> = text
4830            .lines()
4831            .filter_map(|l| {
4832                let parts: Vec<&str> = l.splitn(3, '|').collect();
4833                if parts.len() == 3 {
4834                    Some((
4835                        parts[0].to_string(),
4836                        parts[1].to_string(),
4837                        parts[2].to_string(),
4838                    ))
4839                } else {
4840                    None
4841                }
4842            })
4843            .take(max_entries)
4844            .collect();
4845
4846        if entries.is_empty() {
4847            out.push_str("No startup entries found in the Windows Run registry keys.\n");
4848        } else {
4849            out.push_str("Registry run keys (programs that start with Windows):\n\n");
4850            let mut last_hive = String::new();
4851            for (hive, name, value) in &entries {
4852                if *hive != last_hive {
4853                    out.push_str(&format!("[{}]\n", hive));
4854                    last_hive = hive.clone();
4855                }
4856                // Truncate very long values (paths with many args)
4857                let display = if value.len() > 100 {
4858                    format!("{}…", &value[..100])
4859                } else {
4860                    value.clone()
4861                };
4862                out.push_str(&format!("  {name}: {display}\n"));
4863            }
4864            out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4865        }
4866
4867        // 3. Unified Startup Command check (Task Manager style)
4868        let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { "  $($_.Name): $($_.Command) ($($_.Location))" }"#;
4869        if let Ok(unified_out) = Command::new("powershell")
4870            .args(["-NoProfile", "-Command", unified_script])
4871            .output()
4872        {
4873            let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4874            let trimmed = unified_text.trim();
4875            if !trimmed.is_empty() {
4876                out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4877                out.push_str(trimmed);
4878                out.push('\n');
4879            }
4880        }
4881    }
4882
4883    #[cfg(not(target_os = "windows"))]
4884    {
4885        // On Linux: systemd enabled services + cron @reboot entries.
4886        let output = Command::new("systemctl")
4887            .args([
4888                "list-unit-files",
4889                "--type=service",
4890                "--state=enabled",
4891                "--no-legend",
4892                "--no-pager",
4893                "--plain",
4894            ])
4895            .output();
4896
4897        match output {
4898            Ok(o) if o.status.success() => {
4899                let text = String::from_utf8_lossy(&o.stdout);
4900                let services: Vec<&str> = text
4901                    .lines()
4902                    .filter(|l| !l.trim().is_empty())
4903                    .take(max_entries)
4904                    .collect();
4905                if services.is_empty() {
4906                    out.push_str("No enabled systemd services found.\n");
4907                } else {
4908                    out.push_str("Enabled systemd services (run at boot):\n\n");
4909                    for s in &services {
4910                        out.push_str(&format!("  {s}\n"));
4911                    }
4912                    out.push_str(&format!(
4913                        "\nShowing {} of enabled services.\n",
4914                        services.len()
4915                    ));
4916                }
4917            }
4918            _ => {
4919                out.push_str(
4920                    "systemctl not found on this system. Cannot enumerate startup services.\n",
4921                );
4922            }
4923        }
4924
4925        // Check @reboot cron entries.
4926        if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4927            let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4928            let reboot_entries: Vec<&str> = cron_text
4929                .lines()
4930                .filter(|l| l.trim_start().starts_with("@reboot"))
4931                .collect();
4932            if !reboot_entries.is_empty() {
4933                out.push_str("\nCron @reboot entries:\n");
4934                for e in reboot_entries {
4935                    out.push_str(&format!("  {e}\n"));
4936                }
4937            }
4938        }
4939    }
4940
4941    Ok(out.trim_end().to_string())
4942}
4943
4944fn inspect_os_config() -> Result<String, String> {
4945    let mut out = String::from("Host inspection: OS Configuration\n\n");
4946
4947    #[cfg(target_os = "windows")]
4948    {
4949        // Power Plan
4950        if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4951            let power_str = String::from_utf8_lossy(&power_out.stdout);
4952            out.push_str("=== Power Plan ===\n");
4953            out.push_str(power_str.trim());
4954            out.push_str("\n\n");
4955        }
4956
4957        // Firewall Status
4958        let fw_script =
4959            "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4960        if let Ok(fw_out) = Command::new("powershell")
4961            .args(["-NoProfile", "-Command", fw_script])
4962            .output()
4963        {
4964            let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4965            out.push_str("=== Firewall Profiles ===\n");
4966            out.push_str(fw_str.trim());
4967            out.push_str("\n\n");
4968        }
4969
4970        // System Uptime
4971        let uptime_script =
4972            "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4973        if let Ok(uptime_out) = Command::new("powershell")
4974            .args(["-NoProfile", "-Command", uptime_script])
4975            .output()
4976        {
4977            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4978            out.push_str("=== System Uptime (Last Boot) ===\n");
4979            out.push_str(uptime_str.trim());
4980            out.push_str("\n\n");
4981        }
4982    }
4983
4984    #[cfg(not(target_os = "windows"))]
4985    {
4986        // Uptime
4987        if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4988            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4989            out.push_str("=== System Uptime ===\n");
4990            out.push_str(uptime_str.trim());
4991            out.push_str("\n\n");
4992        }
4993
4994        // Firewall (ufw status if available)
4995        if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4996            let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4997            if !ufw_str.trim().is_empty() {
4998                out.push_str("=== Firewall (UFW) ===\n");
4999                out.push_str(ufw_str.trim());
5000                out.push_str("\n\n");
5001            }
5002        }
5003    }
5004    Ok(out.trim_end().to_string())
5005}
5006
5007pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
5008    let action = args
5009        .get("action")
5010        .and_then(|v| v.as_str())
5011        .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
5012
5013    let target = args
5014        .get("target")
5015        .and_then(|v| v.as_str())
5016        .unwrap_or("")
5017        .trim();
5018
5019    if target.is_empty() && action != "clear_temp" {
5020        return Err("Missing required argument: 'target' for this action".to_string());
5021    }
5022
5023    match action {
5024        "install_package" => {
5025            #[cfg(target_os = "windows")]
5026            {
5027                let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
5028                match Command::new("powershell")
5029                    .args(["-NoProfile", "-Command", &cmd])
5030                    .output()
5031                {
5032                    Ok(out) => Ok(format!(
5033                        "Executed remediation (winget install):\n{}",
5034                        String::from_utf8_lossy(&out.stdout)
5035                    )),
5036                    Err(e) => Err(format!("Failed to run winget: {}", e)),
5037                }
5038            }
5039            #[cfg(not(target_os = "windows"))]
5040            {
5041                Err(
5042                    "install_package via wrapper is only supported on Windows currently (winget)"
5043                        .to_string(),
5044                )
5045            }
5046        }
5047        "restart_service" => {
5048            #[cfg(target_os = "windows")]
5049            {
5050                let cmd = format!("Restart-Service -Name {} -Force", target);
5051                match Command::new("powershell")
5052                    .args(["-NoProfile", "-Command", &cmd])
5053                    .output()
5054                {
5055                    Ok(out) => {
5056                        let err_str = String::from_utf8_lossy(&out.stderr);
5057                        if !err_str.is_empty() {
5058                            return Err(format!("Error restarting service:\n{}", err_str));
5059                        }
5060                        Ok(format!("Successfully restarted service: {}", target))
5061                    }
5062                    Err(e) => Err(format!("Failed to restart service: {}", e)),
5063                }
5064            }
5065            #[cfg(not(target_os = "windows"))]
5066            {
5067                Err(
5068                    "restart_service via wrapper is only supported on Windows currently"
5069                        .to_string(),
5070                )
5071            }
5072        }
5073        "clear_temp" => {
5074            #[cfg(target_os = "windows")]
5075            {
5076                let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
5077                match Command::new("powershell")
5078                    .args(["-NoProfile", "-Command", cmd])
5079                    .output()
5080                {
5081                    Ok(_) => Ok("Successfully cleared temporary files".to_string()),
5082                    Err(e) => Err(format!("Failed to clear temp: {}", e)),
5083                }
5084            }
5085            #[cfg(not(target_os = "windows"))]
5086            {
5087                Err("clear_temp via wrapper is only supported on Windows currently".to_string())
5088            }
5089        }
5090        other => Err(format!("Unknown remediation action: {}", other)),
5091    }
5092}
5093
5094// ── storage ───────────────────────────────────────────────────────────────────
5095
5096fn inspect_storage(max_entries: usize) -> Result<String, String> {
5097    let mut out = String::from("Host inspection: storage\n\n");
5098    let _ = max_entries; // used by non-Windows branch
5099
5100    // ── Drive overview ────────────────────────────────────────────────────────
5101    out.push_str("Drives:\n");
5102
5103    #[cfg(target_os = "windows")]
5104    {
5105        let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
5106    $free = $_.Free
5107    $used = $_.Used
5108    if ($free -eq $null) { $free = 0 }
5109    if ($used -eq $null) { $used = 0 }
5110    $total = $free + $used
5111    "$($_.Name)|$free|$used|$total"
5112}"#;
5113        match Command::new("powershell")
5114            .args(["-NoProfile", "-Command", script])
5115            .output()
5116        {
5117            Ok(o) => {
5118                let text = String::from_utf8_lossy(&o.stdout);
5119                let mut drive_count = 0usize;
5120                for line in text.lines() {
5121                    let parts: Vec<&str> = line.trim().split('|').collect();
5122                    if parts.len() == 4 {
5123                        let name = parts[0];
5124                        let free: u64 = parts[1].parse().unwrap_or(0);
5125                        let total: u64 = parts[3].parse().unwrap_or(0);
5126                        if total == 0 {
5127                            continue;
5128                        }
5129                        let free_gb = free / 1_073_741_824;
5130                        let total_gb = total / 1_073_741_824;
5131                        let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
5132                        let bar_len = 20usize;
5133                        let filled = (used_pct as usize * bar_len / 100).min(bar_len);
5134                        let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
5135                        let warn = if free_gb < 5 {
5136                            " [!] CRITICALLY LOW"
5137                        } else if free_gb < 15 {
5138                            " [-] LOW"
5139                        } else {
5140                            ""
5141                        };
5142                        out.push_str(&format!(
5143                            "  {name}:  [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
5144                        ));
5145                        drive_count += 1;
5146                    }
5147                }
5148                if drive_count == 0 {
5149                    out.push_str("  (could not enumerate drives)\n");
5150                }
5151            }
5152            Err(e) => out.push_str(&format!("  (drive scan failed: {e})\n")),
5153        }
5154
5155        // ── Real-time Performance (Latency) ──────────────────────────────────
5156        let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
5157        match Command::new("powershell")
5158            .args(["-NoProfile", "-Command", latency_script])
5159            .output()
5160        {
5161            Ok(o) => {
5162                out.push_str("\nReal-time Disk Intensity:\n");
5163                let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5164                if !text.is_empty() {
5165                    out.push_str(&format!("  Average Disk Queue Length: {text}\n"));
5166                    if let Ok(q) = text.parse::<f64>() {
5167                        if q > 2.0 {
5168                            out.push_str(
5169                                "  [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
5170                            );
5171                        } else {
5172                            out.push_str("  [~] Disk latency is within healthy bounds.\n");
5173                        }
5174                    }
5175                } else {
5176                    out.push_str("  Average Disk Queue Length: unavailable\n");
5177                }
5178            }
5179            Err(_) => {
5180                out.push_str("\nReal-time Disk Intensity:\n");
5181                out.push_str("  Average Disk Queue Length: unavailable\n");
5182            }
5183        }
5184    }
5185
5186    #[cfg(not(target_os = "windows"))]
5187    {
5188        match Command::new("df")
5189            .args(["-h", "--output=target,size,avail,pcent"])
5190            .output()
5191        {
5192            Ok(o) => {
5193                let text = String::from_utf8_lossy(&o.stdout);
5194                let mut count = 0usize;
5195                for line in text.lines().skip(1) {
5196                    let cols: Vec<&str> = line.split_whitespace().collect();
5197                    if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
5198                        out.push_str(&format!(
5199                            "  {}  size: {}  avail: {}  used: {}\n",
5200                            cols[0], cols[1], cols[2], cols[3]
5201                        ));
5202                        count += 1;
5203                        if count >= max_entries {
5204                            break;
5205                        }
5206                    }
5207                }
5208            }
5209            Err(e) => out.push_str(&format!("  (df failed: {e})\n")),
5210        }
5211    }
5212
5213    // ── Large developer cache directories ─────────────────────────────────────
5214    out.push_str("\nLarge developer cache directories (if present):\n");
5215
5216    #[cfg(target_os = "windows")]
5217    {
5218        let home = std::env::var("USERPROFILE").unwrap_or_default();
5219        let check_dirs: &[(&str, &str)] = &[
5220            ("Temp", r"AppData\Local\Temp"),
5221            ("npm cache", r"AppData\Roaming\npm-cache"),
5222            ("Cargo registry", r".cargo\registry"),
5223            ("Cargo git", r".cargo\git"),
5224            ("pip cache", r"AppData\Local\pip\cache"),
5225            ("Yarn cache", r"AppData\Local\Yarn\Cache"),
5226            (".rustup toolchains", r".rustup\toolchains"),
5227            ("node_modules (home)", r"node_modules"),
5228        ];
5229
5230        let mut found_any = false;
5231        for (label, rel) in check_dirs {
5232            let full = format!(r"{}\{}", home, rel);
5233            let path = std::path::Path::new(&full);
5234            if path.exists() {
5235                // Quick size estimate via PowerShell (non-blocking cap at 5s)
5236                let size_script = format!(
5237                    r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
5238                    full.replace('\'', "''")
5239                );
5240                let size_mb = Command::new("powershell")
5241                    .args(["-NoProfile", "-Command", &size_script])
5242                    .output()
5243                    .ok()
5244                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5245                    .unwrap_or_else(|| "?".to_string());
5246                out.push_str(&format!("  {label}: {size_mb} MB  ({full})\n"));
5247                found_any = true;
5248            }
5249        }
5250        if !found_any {
5251            out.push_str("  (none of the common cache directories found)\n");
5252        }
5253
5254        out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
5255    }
5256
5257    #[cfg(not(target_os = "windows"))]
5258    {
5259        let home = std::env::var("HOME").unwrap_or_default();
5260        let check_dirs: &[(&str, &str)] = &[
5261            ("npm cache", ".npm"),
5262            ("Cargo registry", ".cargo/registry"),
5263            ("pip cache", ".cache/pip"),
5264            (".rustup toolchains", ".rustup/toolchains"),
5265            ("Yarn cache", ".cache/yarn"),
5266        ];
5267        let mut found_any = false;
5268        for (label, rel) in check_dirs {
5269            let full = format!("{}/{}", home, rel);
5270            if std::path::Path::new(&full).exists() {
5271                let size = Command::new("du")
5272                    .args(["-sh", &full])
5273                    .output()
5274                    .ok()
5275                    .map(|o| {
5276                        let s = String::from_utf8_lossy(&o.stdout);
5277                        s.split_whitespace().next().unwrap_or("?").to_string()
5278                    })
5279                    .unwrap_or_else(|| "?".to_string());
5280                out.push_str(&format!("  {label}: {size}  ({full})\n"));
5281                found_any = true;
5282            }
5283        }
5284        if !found_any {
5285            out.push_str("  (none of the common cache directories found)\n");
5286        }
5287    }
5288
5289    Ok(out.trim_end().to_string())
5290}
5291
5292// ── hardware ──────────────────────────────────────────────────────────────────
5293
5294fn inspect_hardware() -> Result<String, String> {
5295    let mut out = String::from("Host inspection: hardware\n\n");
5296
5297    #[cfg(target_os = "windows")]
5298    {
5299        // CPU
5300        let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
5301    "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
5302} | Select-Object -First 1"#;
5303        if let Ok(o) = Command::new("powershell")
5304            .args(["-NoProfile", "-Command", cpu_script])
5305            .output()
5306        {
5307            let text = String::from_utf8_lossy(&o.stdout);
5308            let text = text.trim();
5309            let parts: Vec<&str> = text.split('|').collect();
5310            if parts.len() == 4 {
5311                out.push_str(&format!(
5312                    "CPU: {}\n  {} physical cores, {} logical processors, {:.1} GHz\n\n",
5313                    parts[0],
5314                    parts[1],
5315                    parts[2],
5316                    parts[3].parse::<f32>().unwrap_or(0.0)
5317                ));
5318            } else {
5319                out.push_str(&format!("CPU: {text}\n\n"));
5320            }
5321        }
5322
5323        // RAM (total installed + speed)
5324        let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5325$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5326$speed = ($sticks | Select-Object -First 1).Speed
5327"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5328        if let Ok(o) = Command::new("powershell")
5329            .args(["-NoProfile", "-Command", ram_script])
5330            .output()
5331        {
5332            let text = String::from_utf8_lossy(&o.stdout);
5333            out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
5334        }
5335
5336        // GPU(s)
5337        let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5338    "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5339}"#;
5340        if let Ok(o) = Command::new("powershell")
5341            .args(["-NoProfile", "-Command", gpu_script])
5342            .output()
5343        {
5344            let text = String::from_utf8_lossy(&o.stdout);
5345            let lines: Vec<&str> = text.lines().collect();
5346            if !lines.is_empty() {
5347                out.push_str("GPU(s):\n");
5348                for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5349                    let parts: Vec<&str> = line.trim().split('|').collect();
5350                    if parts.len() == 3 {
5351                        let res = if parts[2] == "x" || parts[2].starts_with('0') {
5352                            String::new()
5353                        } else {
5354                            format!(" — {}@display", parts[2])
5355                        };
5356                        out.push_str(&format!(
5357                            "  {}\n    Driver: {}{}\n",
5358                            parts[0], parts[1], res
5359                        ));
5360                    } else {
5361                        out.push_str(&format!("  {}\n", line.trim()));
5362                    }
5363                }
5364                out.push('\n');
5365            }
5366        }
5367
5368        // Motherboard + BIOS + Virtualization
5369        let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5370$bios = Get-CimInstance Win32_BIOS
5371$cs = Get-CimInstance Win32_ComputerSystem
5372$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5373$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5374"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5375        if let Ok(o) = Command::new("powershell")
5376            .args(["-NoProfile", "-Command", mb_script])
5377            .output()
5378        {
5379            let text = String::from_utf8_lossy(&o.stdout);
5380            let text = text.trim().trim_matches('"');
5381            let parts: Vec<&str> = text.split('|').collect();
5382            if parts.len() == 4 {
5383                out.push_str(&format!(
5384                    "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5385                    parts[0].trim(),
5386                    parts[1].trim(),
5387                    parts[2].trim(),
5388                    parts[3].trim()
5389                ));
5390            }
5391        }
5392
5393        // Display(s)
5394        let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5395    "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5396}"#;
5397        if let Ok(o) = Command::new("powershell")
5398            .args(["-NoProfile", "-Command", disp_script])
5399            .output()
5400        {
5401            let text = String::from_utf8_lossy(&o.stdout);
5402            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5403            if !lines.is_empty() {
5404                out.push_str("Display(s):\n");
5405                for line in &lines {
5406                    let parts: Vec<&str> = line.trim().split('|').collect();
5407                    if parts.len() == 2 {
5408                        out.push_str(&format!("  {} — {}\n", parts[0].trim(), parts[1]));
5409                    }
5410                }
5411            }
5412        }
5413    }
5414
5415    #[cfg(not(target_os = "windows"))]
5416    {
5417        // CPU via /proc/cpuinfo
5418        if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5419            let model = content
5420                .lines()
5421                .find(|l| l.starts_with("model name"))
5422                .and_then(|l| l.split(':').nth(1))
5423                .map(str::trim)
5424                .unwrap_or("unknown");
5425            let cores = content
5426                .lines()
5427                .filter(|l| l.starts_with("processor"))
5428                .count();
5429            out.push_str(&format!("CPU: {model}\n  {cores} logical processors\n\n"));
5430        }
5431
5432        // RAM
5433        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5434            let total_kb: u64 = content
5435                .lines()
5436                .find(|l| l.starts_with("MemTotal:"))
5437                .and_then(|l| l.split_whitespace().nth(1))
5438                .and_then(|v| v.parse().ok())
5439                .unwrap_or(0);
5440            let total_gb = total_kb / 1_048_576;
5441            out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
5442        }
5443
5444        // GPU via lspci
5445        if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5446            let text = String::from_utf8_lossy(&o.stdout);
5447            let gpu_lines: Vec<&str> = text
5448                .lines()
5449                .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5450                .collect();
5451            if !gpu_lines.is_empty() {
5452                out.push_str("GPU(s):\n");
5453                for l in gpu_lines {
5454                    out.push_str(&format!("  {l}\n"));
5455                }
5456                out.push('\n');
5457            }
5458        }
5459
5460        // DMI/BIOS info
5461        if let Ok(o) = Command::new("dmidecode")
5462            .args(["-t", "baseboard", "-t", "bios"])
5463            .output()
5464        {
5465            let text = String::from_utf8_lossy(&o.stdout);
5466            out.push_str("Motherboard/BIOS:\n");
5467            for line in text
5468                .lines()
5469                .filter(|l| {
5470                    l.contains("Manufacturer:")
5471                        || l.contains("Product Name:")
5472                        || l.contains("Version:")
5473                })
5474                .take(6)
5475            {
5476                out.push_str(&format!("  {}\n", line.trim()));
5477            }
5478        }
5479    }
5480
5481    Ok(out.trim_end().to_string())
5482}
5483
5484// ── updates ───────────────────────────────────────────────────────────────────
5485
5486fn inspect_updates() -> Result<String, String> {
5487    let mut out = String::from("Host inspection: updates\n\n");
5488
5489    #[cfg(target_os = "windows")]
5490    {
5491        // Last installed update via COM
5492        let script = r#"
5493try {
5494    $sess = New-Object -ComObject Microsoft.Update.Session
5495    $searcher = $sess.CreateUpdateSearcher()
5496    $count = $searcher.GetTotalHistoryCount()
5497    if ($count -gt 0) {
5498        $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5499        $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5500    } else { "NONE|LAST_INSTALL" }
5501} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5502"#;
5503        if let Ok(o) = Command::new("powershell")
5504            .args(["-NoProfile", "-Command", script])
5505            .output()
5506        {
5507            let raw = String::from_utf8_lossy(&o.stdout);
5508            let text = raw.trim();
5509            if text.starts_with("ERROR:") {
5510                out.push_str("Last update install: (unable to query)\n");
5511            } else if text.contains("NONE") {
5512                out.push_str("Last update install: No update history found\n");
5513            } else {
5514                let date = text.replace("|LAST_INSTALL", "");
5515                out.push_str(&format!("Last update install: {date}\n"));
5516            }
5517        }
5518
5519        // Pending updates count
5520        let pending_script = r#"
5521try {
5522    $sess = New-Object -ComObject Microsoft.Update.Session
5523    $searcher = $sess.CreateUpdateSearcher()
5524    $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5525    $results.Updates.Count.ToString() + "|PENDING"
5526} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5527"#;
5528        if let Ok(o) = Command::new("powershell")
5529            .args(["-NoProfile", "-Command", pending_script])
5530            .output()
5531        {
5532            let raw = String::from_utf8_lossy(&o.stdout);
5533            let text = raw.trim();
5534            if text.starts_with("ERROR:") {
5535                out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5536            } else {
5537                let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5538                if count == 0 {
5539                    out.push_str("Pending updates: Up to date — no updates waiting\n");
5540                } else if count > 0 {
5541                    out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5542                    out.push_str(
5543                        "  → Open Windows Update (Settings > Windows Update) to install\n",
5544                    );
5545                }
5546            }
5547        }
5548
5549        // Windows Update service state
5550        let svc_script = r#"
5551$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5552if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5553"#;
5554        if let Ok(o) = Command::new("powershell")
5555            .args(["-NoProfile", "-Command", svc_script])
5556            .output()
5557        {
5558            let raw = String::from_utf8_lossy(&o.stdout);
5559            let status = raw.trim();
5560            out.push_str(&format!("Windows Update service: {status}\n"));
5561        }
5562    }
5563
5564    #[cfg(not(target_os = "windows"))]
5565    {
5566        let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5567        let mut found = false;
5568        if let Ok(o) = apt_out {
5569            let text = String::from_utf8_lossy(&o.stdout);
5570            let lines: Vec<&str> = text
5571                .lines()
5572                .filter(|l| l.contains('/') && !l.contains("Listing"))
5573                .collect();
5574            if !lines.is_empty() {
5575                out.push_str(&format!(
5576                    "{} package(s) can be upgraded (apt)\n",
5577                    lines.len()
5578                ));
5579                out.push_str("  → Run: sudo apt upgrade\n");
5580                found = true;
5581            }
5582        }
5583        if !found {
5584            if let Ok(o) = Command::new("dnf")
5585                .args(["check-update", "--quiet"])
5586                .output()
5587            {
5588                let text = String::from_utf8_lossy(&o.stdout);
5589                let count = text
5590                    .lines()
5591                    .filter(|l| !l.is_empty() && !l.starts_with('!'))
5592                    .count();
5593                if count > 0 {
5594                    out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5595                    out.push_str("  → Run: sudo dnf upgrade\n");
5596                } else {
5597                    out.push_str("System is up to date.\n");
5598                }
5599            } else {
5600                out.push_str("Could not query package manager for updates.\n");
5601            }
5602        }
5603    }
5604
5605    Ok(out.trim_end().to_string())
5606}
5607
5608// ── security ──────────────────────────────────────────────────────────────────
5609
5610fn inspect_security() -> Result<String, String> {
5611    let mut out = String::from("Host inspection: security\n\n");
5612
5613    #[cfg(target_os = "windows")]
5614    {
5615        // Windows Defender status
5616        let defender_script = r#"
5617try {
5618    $status = Get-MpComputerStatus -ErrorAction Stop
5619    "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5620} catch { "ERROR:" + $_.Exception.Message }
5621"#;
5622        if let Ok(o) = Command::new("powershell")
5623            .args(["-NoProfile", "-Command", defender_script])
5624            .output()
5625        {
5626            let raw = String::from_utf8_lossy(&o.stdout);
5627            let text = raw.trim();
5628            if text.starts_with("ERROR:") {
5629                out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5630            } else {
5631                let get = |key: &str| -> String {
5632                    text.split('|')
5633                        .find(|s| s.starts_with(key))
5634                        .and_then(|s| s.splitn(2, ':').nth(1))
5635                        .unwrap_or("unknown")
5636                        .to_string()
5637                };
5638                let rtp = get("RTP");
5639                let last_scan = {
5640                    // SCAN field has a colon in the time, so grab everything after "SCAN:"
5641                    text.split('|')
5642                        .find(|s| s.starts_with("SCAN:"))
5643                        .and_then(|s| s.get(5..))
5644                        .unwrap_or("unknown")
5645                        .to_string()
5646                };
5647                let def_ver = get("VER");
5648                let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5649
5650                let rtp_label = if rtp == "True" {
5651                    "ENABLED"
5652                } else {
5653                    "DISABLED [!]"
5654                };
5655                out.push_str(&format!(
5656                    "Windows Defender real-time protection: {rtp_label}\n"
5657                ));
5658                out.push_str(&format!("Last quick scan: {last_scan}\n"));
5659                out.push_str(&format!("Signature version: {def_ver}\n"));
5660                if age_days >= 0 {
5661                    let freshness = if age_days == 0 {
5662                        "up to date".to_string()
5663                    } else if age_days <= 3 {
5664                        format!("{age_days} day(s) old — OK")
5665                    } else if age_days <= 7 {
5666                        format!("{age_days} day(s) old — consider updating")
5667                    } else {
5668                        format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5669                    };
5670                    out.push_str(&format!("Signature age: {freshness}\n"));
5671                }
5672                if rtp != "True" {
5673                    out.push_str(
5674                        "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5675                    );
5676                    out.push_str(
5677                        "    → Open Windows Security > Virus & threat protection to re-enable.\n",
5678                    );
5679                }
5680            }
5681        }
5682
5683        out.push('\n');
5684
5685        // Windows Firewall state
5686        let fw_script = r#"
5687try {
5688    Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5689} catch { "ERROR:" + $_.Exception.Message }
5690"#;
5691        if let Ok(o) = Command::new("powershell")
5692            .args(["-NoProfile", "-Command", fw_script])
5693            .output()
5694        {
5695            let raw = String::from_utf8_lossy(&o.stdout);
5696            let text = raw.trim();
5697            if !text.starts_with("ERROR:") && !text.is_empty() {
5698                out.push_str("Windows Firewall:\n");
5699                for line in text.lines() {
5700                    if let Some((name, enabled)) = line.split_once(':') {
5701                        let state = if enabled.trim() == "True" {
5702                            "ON"
5703                        } else {
5704                            "OFF [!]"
5705                        };
5706                        out.push_str(&format!("  {name}: {state}\n"));
5707                    }
5708                }
5709                out.push('\n');
5710            }
5711        }
5712
5713        // Windows activation status
5714        let act_script = r#"
5715try {
5716    $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5717    if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5718} catch { "UNKNOWN" }
5719"#;
5720        if let Ok(o) = Command::new("powershell")
5721            .args(["-NoProfile", "-Command", act_script])
5722            .output()
5723        {
5724            let raw = String::from_utf8_lossy(&o.stdout);
5725            match raw.trim() {
5726                "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5727                "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5728                _ => out.push_str("Windows activation: Unable to determine\n"),
5729            }
5730        }
5731
5732        // UAC state
5733        let uac_script = r#"
5734$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5735if ($val -eq 1) { "ON" } else { "OFF" }
5736"#;
5737        if let Ok(o) = Command::new("powershell")
5738            .args(["-NoProfile", "-Command", uac_script])
5739            .output()
5740        {
5741            let raw = String::from_utf8_lossy(&o.stdout);
5742            let state = raw.trim();
5743            let label = if state == "ON" {
5744                "Enabled"
5745            } else {
5746                "DISABLED [!] — recommended to re-enable via secpol.msc"
5747            };
5748            out.push_str(&format!("UAC (User Account Control): {label}\n"));
5749        }
5750    }
5751
5752    #[cfg(not(target_os = "windows"))]
5753    {
5754        if let Ok(o) = Command::new("ufw").arg("status").output() {
5755            let text = String::from_utf8_lossy(&o.stdout);
5756            out.push_str(&format!(
5757                "UFW: {}\n",
5758                text.lines().next().unwrap_or("unknown")
5759            ));
5760        }
5761        if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5762            if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5763                out.push_str(&format!("{line}\n"));
5764            }
5765        }
5766    }
5767
5768    Ok(out.trim_end().to_string())
5769}
5770
5771// ── pending_reboot ────────────────────────────────────────────────────────────
5772
5773fn inspect_pending_reboot() -> Result<String, String> {
5774    let mut out = String::from("Host inspection: pending_reboot\n\n");
5775
5776    #[cfg(target_os = "windows")]
5777    {
5778        let script = r#"
5779$reasons = @()
5780if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5781    $reasons += "Windows Update requires a restart"
5782}
5783if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5784    $reasons += "Windows component install/update requires a restart"
5785}
5786$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5787if ($pfro -and $pfro.PendingFileRenameOperations) {
5788    $reasons += "Pending file rename operations (driver or system file replacement)"
5789}
5790if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5791"#;
5792        let output = Command::new("powershell")
5793            .args(["-NoProfile", "-Command", script])
5794            .output()
5795            .map_err(|e| format!("pending_reboot: {e}"))?;
5796
5797        let raw = String::from_utf8_lossy(&output.stdout);
5798        let text = raw.trim();
5799
5800        if text == "NO_REBOOT_NEEDED" {
5801            out.push_str("No restart required — system is up to date and stable.\n");
5802        } else if text.is_empty() {
5803            out.push_str("Could not determine reboot status.\n");
5804        } else {
5805            out.push_str("[!] A system restart is pending:\n\n");
5806            for reason in text.split("|REASON|") {
5807                out.push_str(&format!("  • {}\n", reason.trim()));
5808            }
5809            out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5810        }
5811    }
5812
5813    #[cfg(not(target_os = "windows"))]
5814    {
5815        if std::path::Path::new("/var/run/reboot-required").exists() {
5816            out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5817            if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5818                out.push_str("Packages requiring restart:\n");
5819                for p in pkgs.lines().take(10) {
5820                    out.push_str(&format!("  • {p}\n"));
5821                }
5822            }
5823        } else {
5824            out.push_str("No restart required.\n");
5825        }
5826    }
5827
5828    Ok(out.trim_end().to_string())
5829}
5830
5831// ── disk_health ───────────────────────────────────────────────────────────────
5832
5833fn inspect_disk_health() -> Result<String, String> {
5834    let mut out = String::from("Host inspection: disk_health\n\n");
5835
5836    #[cfg(target_os = "windows")]
5837    {
5838        let script = r#"
5839try {
5840    $disks = Get-PhysicalDisk -ErrorAction Stop
5841    foreach ($d in $disks) {
5842        $size_gb = [math]::Round($d.Size / 1GB, 0)
5843        $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5844    }
5845} catch { "ERROR:" + $_.Exception.Message }
5846"#;
5847        let output = Command::new("powershell")
5848            .args(["-NoProfile", "-Command", script])
5849            .output()
5850            .map_err(|e| format!("disk_health: {e}"))?;
5851
5852        let raw = String::from_utf8_lossy(&output.stdout);
5853        let text = raw.trim();
5854
5855        if text.starts_with("ERROR:") {
5856            out.push_str(&format!("Unable to query disk health: {text}\n"));
5857            out.push_str("This may require running as administrator.\n");
5858        } else if text.is_empty() {
5859            out.push_str("No physical disks found.\n");
5860        } else {
5861            out.push_str("Physical Drive Health:\n\n");
5862            for line in text.lines() {
5863                let parts: Vec<&str> = line.splitn(5, '|').collect();
5864                if parts.len() >= 4 {
5865                    let name = parts[0];
5866                    let media = parts[1];
5867                    let size = parts[2];
5868                    let health = parts[3];
5869                    let op_status = parts.get(4).unwrap_or(&"");
5870                    let health_label = match health.trim() {
5871                        "Healthy" => "OK",
5872                        "Warning" => "[!] WARNING",
5873                        "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5874                        other => other,
5875                    };
5876                    out.push_str(&format!("  {name}\n"));
5877                    out.push_str(&format!("    Type: {media} | Size: {size}\n"));
5878                    out.push_str(&format!("    Health: {health_label}\n"));
5879                    if !op_status.is_empty() {
5880                        out.push_str(&format!("    Status: {op_status}\n"));
5881                    }
5882                    out.push('\n');
5883                }
5884            }
5885        }
5886
5887        // SMART failure prediction (best-effort, may need admin)
5888        let smart_script = r#"
5889try {
5890    Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5891        ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5892} catch { "" }
5893"#;
5894        if let Ok(o) = Command::new("powershell")
5895            .args(["-NoProfile", "-Command", smart_script])
5896            .output()
5897        {
5898            let raw2 = String::from_utf8_lossy(&o.stdout);
5899            let text2 = raw2.trim();
5900            if !text2.is_empty() {
5901                let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5902                if failures.is_empty() {
5903                    out.push_str("SMART failure prediction: No failures predicted\n");
5904                } else {
5905                    out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5906                    for f in failures {
5907                        let name = f.split('|').next().unwrap_or(f);
5908                        out.push_str(&format!("  • {name}\n"));
5909                    }
5910                    out.push_str(
5911                        "\nBack up your data immediately and replace the failing drive.\n",
5912                    );
5913                }
5914            }
5915        }
5916    }
5917
5918    #[cfg(not(target_os = "windows"))]
5919    {
5920        if let Ok(o) = Command::new("lsblk")
5921            .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5922            .output()
5923        {
5924            let text = String::from_utf8_lossy(&o.stdout);
5925            out.push_str("Block devices:\n");
5926            out.push_str(text.trim());
5927            out.push('\n');
5928        }
5929        if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5930            let devices = String::from_utf8_lossy(&scan.stdout);
5931            for dev_line in devices.lines().take(4) {
5932                let dev = dev_line.split_whitespace().next().unwrap_or("");
5933                if dev.is_empty() {
5934                    continue;
5935                }
5936                if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5937                    let health = String::from_utf8_lossy(&o.stdout);
5938                    if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5939                    {
5940                        out.push_str(&format!("{dev}: {}\n", line.trim()));
5941                    }
5942                }
5943            }
5944        } else {
5945            out.push_str("(install smartmontools for SMART health data)\n");
5946        }
5947    }
5948
5949    Ok(out.trim_end().to_string())
5950}
5951
5952// ── battery ───────────────────────────────────────────────────────────────────
5953
5954fn inspect_battery() -> Result<String, String> {
5955    let mut out = String::from("Host inspection: battery\n\n");
5956
5957    #[cfg(target_os = "windows")]
5958    {
5959        let script = r#"
5960try {
5961    $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5962    if (-not $bats) { "NO_BATTERY"; exit }
5963    
5964    # Modern Battery Health (Cycle count + Capacity health)
5965    $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5966    $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue 
5967    $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5968
5969    foreach ($b in $bats) {
5970        $state = switch ($b.BatteryStatus) {
5971            1 { "Discharging" }
5972            2 { "AC Power (Fully Charged)" }
5973            3 { "AC Power (Charging)" }
5974            default { "Status $($b.BatteryStatus)" }
5975        }
5976        
5977        $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5978        $health = if ($static -and $full) {
5979             [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5980        } else { "unknown" }
5981
5982        $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5983    }
5984} catch { "ERROR:" + $_.Exception.Message }
5985"#;
5986        let output = Command::new("powershell")
5987            .args(["-NoProfile", "-Command", script])
5988            .output()
5989            .map_err(|e| format!("battery: {e}"))?;
5990
5991        let raw = String::from_utf8_lossy(&output.stdout);
5992        let text = raw.trim();
5993
5994        if text == "NO_BATTERY" {
5995            out.push_str("No battery detected — desktop or AC-only system.\n");
5996            return Ok(out.trim_end().to_string());
5997        }
5998        if text.starts_with("ERROR:") {
5999            out.push_str(&format!("Unable to query battery: {text}\n"));
6000            return Ok(out.trim_end().to_string());
6001        }
6002
6003        for line in text.lines() {
6004            let parts: Vec<&str> = line.split('|').collect();
6005            if parts.len() == 5 {
6006                let name = parts[0];
6007                let charge: i64 = parts[1].parse().unwrap_or(-1);
6008                let state = parts[2];
6009                let cycles = parts[3];
6010                let health = parts[4];
6011
6012                out.push_str(&format!("Battery: {name}\n"));
6013                if charge >= 0 {
6014                    let bar_filled = (charge as usize * 20) / 100;
6015                    out.push_str(&format!(
6016                        "  Charge: [{}{}] {}%\n",
6017                        "#".repeat(bar_filled),
6018                        ".".repeat(20 - bar_filled),
6019                        charge
6020                    ));
6021                }
6022                out.push_str(&format!("  Status: {state}\n"));
6023                out.push_str(&format!("  Cycles: {cycles}\n"));
6024                out.push_str(&format!(
6025                    "  Health: {health}% (Actual vs Design Capacity)\n\n"
6026                ));
6027            }
6028        }
6029    }
6030
6031    #[cfg(not(target_os = "windows"))]
6032    {
6033        let power_path = std::path::Path::new("/sys/class/power_supply");
6034        let mut found = false;
6035        if power_path.exists() {
6036            if let Ok(entries) = std::fs::read_dir(power_path) {
6037                for entry in entries.flatten() {
6038                    let p = entry.path();
6039                    if let Ok(t) = std::fs::read_to_string(p.join("type")) {
6040                        if t.trim() == "Battery" {
6041                            found = true;
6042                            let name = p
6043                                .file_name()
6044                                .unwrap_or_default()
6045                                .to_string_lossy()
6046                                .to_string();
6047                            out.push_str(&format!("Battery: {name}\n"));
6048                            let read = |f: &str| {
6049                                std::fs::read_to_string(p.join(f))
6050                                    .ok()
6051                                    .map(|s| s.trim().to_string())
6052                            };
6053                            if let Some(cap) = read("capacity") {
6054                                out.push_str(&format!("  Charge: {cap}%\n"));
6055                            }
6056                            if let Some(status) = read("status") {
6057                                out.push_str(&format!("  Status: {status}\n"));
6058                            }
6059                            if let (Some(full), Some(design)) =
6060                                (read("energy_full"), read("energy_full_design"))
6061                            {
6062                                if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
6063                                {
6064                                    if d > 0.0 {
6065                                        out.push_str(&format!(
6066                                            "  Wear level: {:.1}% of design capacity\n",
6067                                            (f / d) * 100.0
6068                                        ));
6069                                    }
6070                                }
6071                            }
6072                        }
6073                    }
6074                }
6075            }
6076        }
6077        if !found {
6078            out.push_str("No battery found.\n");
6079        }
6080    }
6081
6082    Ok(out.trim_end().to_string())
6083}
6084
6085// ── recent_crashes ────────────────────────────────────────────────────────────
6086
6087fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
6088    let mut out = String::from("Host inspection: recent_crashes\n\n");
6089    let n = max_entries.clamp(1, 30);
6090
6091    #[cfg(target_os = "windows")]
6092    {
6093        // BSODs / unexpected shutdowns (EventID 41 = kernel power, 1001 = BugCheck)
6094        let bsod_script = format!(
6095            r#"
6096try {{
6097    $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
6098    if ($events) {{
6099        $events | ForEach-Object {{
6100            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6101        }}
6102    }} else {{ "NO_BSOD" }}
6103}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6104        );
6105
6106        if let Ok(o) = Command::new("powershell")
6107            .args(["-NoProfile", "-Command", &bsod_script])
6108            .output()
6109        {
6110            let raw = String::from_utf8_lossy(&o.stdout);
6111            let text = raw.trim();
6112            if text == "NO_BSOD" {
6113                out.push_str("System crashes (BSOD/kernel): None in recent history\n");
6114            } else if text.starts_with("ERROR:") {
6115                out.push_str("System crashes: unable to query\n");
6116            } else {
6117                out.push_str("System crashes / unexpected shutdowns:\n");
6118                for line in text.lines() {
6119                    let parts: Vec<&str> = line.splitn(3, '|').collect();
6120                    if parts.len() >= 3 {
6121                        let time = parts[0];
6122                        let id = parts[1];
6123                        let msg = parts[2];
6124                        let label = if id == "41" {
6125                            "Unexpected shutdown"
6126                        } else {
6127                            "BSOD (BugCheck)"
6128                        };
6129                        out.push_str(&format!("  [{time}] {label}: {msg}\n"));
6130                    }
6131                }
6132                out.push('\n');
6133            }
6134        }
6135
6136        // Application crashes (EventID 1000 = app crash, 1002 = app hang)
6137        let app_script = format!(
6138            r#"
6139try {{
6140    $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
6141    if ($crashes) {{
6142        $crashes | ForEach-Object {{
6143            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6144        }}
6145    }} else {{ "NO_CRASHES" }}
6146}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
6147        );
6148
6149        if let Ok(o) = Command::new("powershell")
6150            .args(["-NoProfile", "-Command", &app_script])
6151            .output()
6152        {
6153            let raw = String::from_utf8_lossy(&o.stdout);
6154            let text = raw.trim();
6155            if text == "NO_CRASHES" {
6156                out.push_str("Application crashes: None in recent history\n");
6157            } else if text.starts_with("ERROR_APP:") {
6158                out.push_str("Application crashes: unable to query\n");
6159            } else {
6160                out.push_str("Application crashes:\n");
6161                for line in text.lines().take(n) {
6162                    let parts: Vec<&str> = line.splitn(2, '|').collect();
6163                    if parts.len() >= 2 {
6164                        out.push_str(&format!("  [{}] {}\n", parts[0], parts[1]));
6165                    }
6166                }
6167            }
6168        }
6169    }
6170
6171    #[cfg(not(target_os = "windows"))]
6172    {
6173        let n_str = n.to_string();
6174        if let Ok(o) = Command::new("journalctl")
6175            .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
6176            .output()
6177        {
6178            let text = String::from_utf8_lossy(&o.stdout);
6179            let trimmed = text.trim();
6180            if trimmed.is_empty() || trimmed.contains("No entries") {
6181                out.push_str("No kernel panics or critical crashes found.\n");
6182            } else {
6183                out.push_str("Kernel critical events:\n");
6184                out.push_str(trimmed);
6185                out.push('\n');
6186            }
6187        }
6188        if let Ok(o) = Command::new("coredumpctl")
6189            .args(["list", "--no-pager"])
6190            .output()
6191        {
6192            let text = String::from_utf8_lossy(&o.stdout);
6193            let count = text
6194                .lines()
6195                .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
6196                .count();
6197            if count > 0 {
6198                out.push_str(&format!(
6199                    "\nCore dumps on file: {count}\n  → Run: coredumpctl list\n"
6200                ));
6201            }
6202        }
6203    }
6204
6205    Ok(out.trim_end().to_string())
6206}
6207
6208// ── scheduled_tasks ───────────────────────────────────────────────────────────
6209
6210fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
6211    let mut out = String::from("Host inspection: scheduled_tasks\n\n");
6212    let n = max_entries.clamp(1, 30);
6213
6214    #[cfg(target_os = "windows")]
6215    {
6216        let script = format!(
6217            r#"
6218try {{
6219    $tasks = Get-ScheduledTask -ErrorAction Stop |
6220        Where-Object {{ $_.State -ne 'Disabled' }} |
6221        ForEach-Object {{
6222            $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
6223            $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
6224                $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
6225            }} else {{ "never" }}
6226            $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
6227            $exec = ($_.Actions | Select-Object -First 1).Execute
6228            if (-not $exec) {{ $exec = "(no exec)" }}
6229            $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
6230        }}
6231    $tasks | Select-Object -First {n}
6232}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6233        );
6234
6235        let output = Command::new("powershell")
6236            .args(["-NoProfile", "-Command", &script])
6237            .output()
6238            .map_err(|e| format!("scheduled_tasks: {e}"))?;
6239
6240        let raw = String::from_utf8_lossy(&output.stdout);
6241        let text = raw.trim();
6242
6243        if text.starts_with("ERROR:") {
6244            out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
6245        } else if text.is_empty() {
6246            out.push_str("No active scheduled tasks found.\n");
6247        } else {
6248            out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
6249            for line in text.lines() {
6250                let parts: Vec<&str> = line.splitn(6, '|').collect();
6251                if parts.len() >= 5 {
6252                    let name = parts[0];
6253                    let path = parts[1];
6254                    let state = parts[2];
6255                    let last = parts[3];
6256                    let res = parts[4];
6257                    let exec = parts.get(5).unwrap_or(&"").trim();
6258                    let display_path = path.trim_matches('\\');
6259                    let display_path = if display_path.is_empty() {
6260                        "Root"
6261                    } else {
6262                        display_path
6263                    };
6264                    out.push_str(&format!("  {name} [{display_path}]\n"));
6265                    out.push_str(&format!(
6266                        "    State: {state} | Last run: {last} | Result: {res}\n"
6267                    ));
6268                    if !exec.is_empty() && exec != "(no exec)" {
6269                        let short = if exec.len() > 80 { &exec[..80] } else { exec };
6270                        out.push_str(&format!("    Runs: {short}\n"));
6271                    }
6272                }
6273            }
6274        }
6275    }
6276
6277    #[cfg(not(target_os = "windows"))]
6278    {
6279        if let Ok(o) = Command::new("systemctl")
6280            .args(["list-timers", "--no-pager", "--all"])
6281            .output()
6282        {
6283            let text = String::from_utf8_lossy(&o.stdout);
6284            out.push_str("Systemd timers:\n");
6285            for l in text
6286                .lines()
6287                .filter(|l| {
6288                    !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
6289                })
6290                .take(n)
6291            {
6292                out.push_str(&format!("  {l}\n"));
6293            }
6294            out.push('\n');
6295        }
6296        if let Ok(o) = Command::new("crontab").arg("-l").output() {
6297            let text = String::from_utf8_lossy(&o.stdout);
6298            let jobs: Vec<&str> = text
6299                .lines()
6300                .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
6301                .collect();
6302            if !jobs.is_empty() {
6303                out.push_str("User crontab:\n");
6304                for j in jobs.iter().take(n) {
6305                    out.push_str(&format!("  {j}\n"));
6306                }
6307            }
6308        }
6309    }
6310
6311    Ok(out.trim_end().to_string())
6312}
6313
6314// ── dev_conflicts ─────────────────────────────────────────────────────────────
6315
6316fn inspect_dev_conflicts() -> Result<String, String> {
6317    let mut out = String::from("Host inspection: dev_conflicts\n\n");
6318    let mut conflicts: Vec<String> = Vec::new();
6319    let mut notes: Vec<String> = Vec::new();
6320
6321    // ── Node.js / version managers ────────────────────────────────────────────
6322    {
6323        let node_ver = Command::new("node")
6324            .arg("--version")
6325            .output()
6326            .ok()
6327            .and_then(|o| String::from_utf8(o.stdout).ok())
6328            .map(|s| s.trim().to_string());
6329        let nvm_active = Command::new("nvm")
6330            .arg("current")
6331            .output()
6332            .ok()
6333            .and_then(|o| String::from_utf8(o.stdout).ok())
6334            .map(|s| s.trim().to_string())
6335            .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6336        let fnm_active = Command::new("fnm")
6337            .arg("current")
6338            .output()
6339            .ok()
6340            .and_then(|o| String::from_utf8(o.stdout).ok())
6341            .map(|s| s.trim().to_string())
6342            .filter(|s| !s.is_empty() && !s.contains("none"));
6343        let volta_active = Command::new("volta")
6344            .args(["which", "node"])
6345            .output()
6346            .ok()
6347            .and_then(|o| String::from_utf8(o.stdout).ok())
6348            .map(|s| s.trim().to_string())
6349            .filter(|s| !s.is_empty());
6350
6351        out.push_str("Node.js:\n");
6352        if let Some(ref v) = node_ver {
6353            out.push_str(&format!("  Active: {v}\n"));
6354        } else {
6355            out.push_str("  Not installed\n");
6356        }
6357        let managers: Vec<&str> = [
6358            nvm_active.as_deref(),
6359            fnm_active.as_deref(),
6360            volta_active.as_deref(),
6361        ]
6362        .iter()
6363        .filter_map(|x| *x)
6364        .collect();
6365        if managers.len() > 1 {
6366            conflicts.push("Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts.".to_string());
6367        } else if !managers.is_empty() {
6368            out.push_str(&format!("  Version manager: {}\n", managers[0]));
6369        }
6370        out.push('\n');
6371    }
6372
6373    // ── Python ────────────────────────────────────────────────────────────────
6374    {
6375        let py3 = Command::new("python3")
6376            .arg("--version")
6377            .output()
6378            .ok()
6379            .and_then(|o| {
6380                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6381                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6382                let v = if stdout.is_empty() { stderr } else { stdout };
6383                if v.is_empty() {
6384                    None
6385                } else {
6386                    Some(v)
6387                }
6388            });
6389        let py = Command::new("python")
6390            .arg("--version")
6391            .output()
6392            .ok()
6393            .and_then(|o| {
6394                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6395                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6396                let v = if stdout.is_empty() { stderr } else { stdout };
6397                if v.is_empty() {
6398                    None
6399                } else {
6400                    Some(v)
6401                }
6402            });
6403        let pyenv = Command::new("pyenv")
6404            .arg("version")
6405            .output()
6406            .ok()
6407            .and_then(|o| String::from_utf8(o.stdout).ok())
6408            .map(|s| s.trim().to_string())
6409            .filter(|s| !s.is_empty());
6410        let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6411
6412        out.push_str("Python:\n");
6413        match (&py3, &py) {
6414            (Some(v3), Some(v)) if v3 != v => {
6415                out.push_str(&format!("  python3: {v3}\n  python:  {v}\n"));
6416                if v.contains("2.") {
6417                    conflicts.push(
6418                        "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6419                    );
6420                } else {
6421                    notes.push(
6422                        "python and python3 resolve to different minor versions.".to_string(),
6423                    );
6424                }
6425            }
6426            (Some(v3), None) => out.push_str(&format!("  python3: {v3}\n")),
6427            (None, Some(v)) => out.push_str(&format!("  python: {v}\n")),
6428            (Some(v3), Some(_)) => out.push_str(&format!("  {v3}\n")),
6429            (None, None) => out.push_str("  Not installed\n"),
6430        }
6431        if let Some(ref pe) = pyenv {
6432            out.push_str(&format!("  pyenv: {pe}\n"));
6433        }
6434        if let Some(env) = conda_env {
6435            if env == "base" {
6436                notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6437            } else {
6438                out.push_str(&format!("  conda env: {env}\n"));
6439            }
6440        }
6441        out.push('\n');
6442    }
6443
6444    // ── Rust / Cargo ──────────────────────────────────────────────────────────
6445    {
6446        let toolchain = Command::new("rustup")
6447            .args(["show", "active-toolchain"])
6448            .output()
6449            .ok()
6450            .and_then(|o| String::from_utf8(o.stdout).ok())
6451            .map(|s| s.trim().to_string())
6452            .filter(|s| !s.is_empty());
6453        let cargo_ver = Command::new("cargo")
6454            .arg("--version")
6455            .output()
6456            .ok()
6457            .and_then(|o| String::from_utf8(o.stdout).ok())
6458            .map(|s| s.trim().to_string());
6459        let rustc_ver = Command::new("rustc")
6460            .arg("--version")
6461            .output()
6462            .ok()
6463            .and_then(|o| String::from_utf8(o.stdout).ok())
6464            .map(|s| s.trim().to_string());
6465
6466        out.push_str("Rust:\n");
6467        if let Some(ref t) = toolchain {
6468            out.push_str(&format!("  Active toolchain: {t}\n"));
6469        }
6470        if let Some(ref c) = cargo_ver {
6471            out.push_str(&format!("  {c}\n"));
6472        }
6473        if let Some(ref r) = rustc_ver {
6474            out.push_str(&format!("  {r}\n"));
6475        }
6476        if cargo_ver.is_none() && rustc_ver.is_none() {
6477            out.push_str("  Not installed\n");
6478        }
6479
6480        // Detect system rust that might shadow rustup
6481        #[cfg(not(target_os = "windows"))]
6482        if let Ok(o) = Command::new("which").arg("rustc").output() {
6483            let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6484            if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6485                conflicts.push(format!(
6486                    "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6487                ));
6488            }
6489        }
6490        out.push('\n');
6491    }
6492
6493    // ── Git ───────────────────────────────────────────────────────────────────
6494    {
6495        let git_ver = Command::new("git")
6496            .arg("--version")
6497            .output()
6498            .ok()
6499            .and_then(|o| String::from_utf8(o.stdout).ok())
6500            .map(|s| s.trim().to_string());
6501        out.push_str("Git:\n");
6502        if let Some(ref v) = git_ver {
6503            out.push_str(&format!("  {v}\n"));
6504            let email = Command::new("git")
6505                .args(["config", "--global", "user.email"])
6506                .output()
6507                .ok()
6508                .and_then(|o| String::from_utf8(o.stdout).ok())
6509                .map(|s| s.trim().to_string());
6510            if let Some(ref e) = email {
6511                if e.is_empty() {
6512                    notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6513                } else {
6514                    out.push_str(&format!("  user.email: {e}\n"));
6515                }
6516            }
6517            let gpg_sign = Command::new("git")
6518                .args(["config", "--global", "commit.gpgsign"])
6519                .output()
6520                .ok()
6521                .and_then(|o| String::from_utf8(o.stdout).ok())
6522                .map(|s| s.trim().to_string());
6523            if gpg_sign.as_deref() == Some("true") {
6524                let key = Command::new("git")
6525                    .args(["config", "--global", "user.signingkey"])
6526                    .output()
6527                    .ok()
6528                    .and_then(|o| String::from_utf8(o.stdout).ok())
6529                    .map(|s| s.trim().to_string());
6530                if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6531                    conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6532                }
6533            }
6534        } else {
6535            out.push_str("  Not installed\n");
6536        }
6537        out.push('\n');
6538    }
6539
6540    // ── PATH duplicates ───────────────────────────────────────────────────────
6541    {
6542        let path_env = std::env::var("PATH").unwrap_or_default();
6543        let sep = if cfg!(windows) { ';' } else { ':' };
6544        let mut seen = HashSet::new();
6545        let mut dupes: Vec<String> = Vec::new();
6546        for p in path_env.split(sep) {
6547            let norm = p.trim().to_lowercase();
6548            if !norm.is_empty() && !seen.insert(norm) {
6549                dupes.push(p.to_string());
6550            }
6551        }
6552        if !dupes.is_empty() {
6553            let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6554            notes.push(format!(
6555                "Duplicate PATH entries: {} {}",
6556                shown.join(", "),
6557                if dupes.len() > 3 {
6558                    format!("+{} more", dupes.len() - 3)
6559                } else {
6560                    String::new()
6561                }
6562            ));
6563        }
6564    }
6565
6566    // ── Summary ───────────────────────────────────────────────────────────────
6567    if conflicts.is_empty() && notes.is_empty() {
6568        out.push_str("No conflicts detected — dev environment looks clean.\n");
6569    } else {
6570        if !conflicts.is_empty() {
6571            out.push_str("CONFLICTS:\n");
6572            for c in &conflicts {
6573                out.push_str(&format!("  [!] {c}\n"));
6574            }
6575            out.push('\n');
6576        }
6577        if !notes.is_empty() {
6578            out.push_str("NOTES:\n");
6579            for n in &notes {
6580                out.push_str(&format!("  [-] {n}\n"));
6581            }
6582        }
6583    }
6584
6585    Ok(out.trim_end().to_string())
6586}
6587
6588// ── connectivity ──────────────────────────────────────────────────────────────
6589
6590async fn inspect_public_ip() -> Result<String, String> {
6591    let mut out = String::from("Host inspection: public_ip\n\n");
6592
6593    let client = reqwest::Client::builder()
6594        .timeout(std::time::Duration::from_secs(5))
6595        .build()
6596        .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
6597
6598    match client.get("https://api.ipify.org?format=json").send().await {
6599        Ok(resp) => {
6600            if let Ok(json) = resp.json::<serde_json::Value>().await {
6601                let ip = json.get("ip").and_then(|v| v.as_str()).unwrap_or("Unknown");
6602                out.push_str(&format!("Public IP: {}\n", ip));
6603
6604                // Geo info
6605                if let Ok(geo_resp) = client
6606                    .get(format!("http://ip-api.com/json/{}", ip))
6607                    .send()
6608                    .await
6609                {
6610                    if let Ok(geo_json) = geo_resp.json::<serde_json::Value>().await {
6611                        if let (Some(city), Some(region), Some(country), Some(isp)) = (
6612                            geo_json.get("city").and_then(|v| v.as_str()),
6613                            geo_json.get("regionName").and_then(|v| v.as_str()),
6614                            geo_json.get("country").and_then(|v| v.as_str()),
6615                            geo_json.get("isp").and_then(|v| v.as_str()),
6616                        ) {
6617                            out.push_str(&format!(
6618                                "Location:  {}, {} ({})\n",
6619                                city, region, country
6620                            ));
6621                            out.push_str(&format!("ISP:       {}\n", isp));
6622                        }
6623                    }
6624                }
6625            } else {
6626                out.push_str("Error: Failed to parse public IP response.\n");
6627            }
6628        }
6629        Err(e) => {
6630            out.push_str(&format!(
6631                "Error: Failed to fetch public IP ({}). Check internet connectivity.\n",
6632                e
6633            ));
6634        }
6635    }
6636
6637    Ok(out)
6638}
6639
6640fn inspect_ssl_cert(host: &str) -> Result<String, String> {
6641    let mut out = format!("Host inspection: ssl_cert (Target: {})\n\n", host);
6642
6643    #[cfg(target_os = "windows")]
6644    {
6645        use std::process::Command;
6646        let script = format!(
6647            r#"$domain = "{host}"
6648try {{
6649    $tcpClient = New-Object System.Net.Sockets.TcpClient($domain, 443)
6650    $sslStream = New-Object System.Net.Security.SslStream($tcpClient.GetStream(), $false, ({{ $true }} -as [System.Net.Security.RemoteCertificateValidationCallback]))
6651    $sslStream.AuthenticateAsClient($domain)
6652    $cert = $sslStream.RemoteCertificate
6653    $tcpClient.Close()
6654    if ($cert) {{
6655        $cert2 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)
6656        $cert2 | Select-Object Subject, Issuer, NotBefore, NotAfter, Thumbprint, SerialNumber | ConvertTo-Json
6657    }} else {{
6658        "null"
6659    }}
6660}} catch {{
6661    "ERROR:" + $_.Exception.Message
6662}}"#
6663        );
6664
6665        let ps_out = Command::new("powershell")
6666            .args(["-NoProfile", "-NonInteractive", "-Command", &script])
6667            .output()
6668            .map_err(|e| format!("powershell launch failed: {e}"))?;
6669
6670        let text = String::from_utf8_lossy(&ps_out.stdout).trim().to_string();
6671        if text.starts_with("ERROR:") {
6672            out.push_str(&format!("Error: {}\n", text.trim_start_matches("ERROR:")));
6673        } else if text == "null" || text.is_empty() {
6674            out.push_str("Error: Could not retrieve certificate. Target may be unreachable or not using SSL.\n");
6675        } else {
6676            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
6677                if let Some(obj) = json.as_object() {
6678                    for (k, v) in obj {
6679                        let val_str = v.as_str().unwrap_or("");
6680                        out.push_str(&format!("{:<12}: {}\n", k, val_str));
6681                    }
6682
6683                    if let Some(not_after_raw) = obj.get("NotAfter").and_then(|v| v.as_str()) {
6684                        if not_after_raw.starts_with("/Date(") {
6685                            let ts = not_after_raw
6686                                .trim_start_matches("/Date(")
6687                                .trim_end_matches(")/")
6688                                .parse::<i64>()
6689                                .unwrap_or(0);
6690                            let expiry =
6691                                chrono::DateTime::from_timestamp(ts / 1000, 0).unwrap_or_default();
6692                            let now = chrono::Utc::now();
6693                            let days_left = expiry.signed_duration_since(now).num_days();
6694                            if days_left < 0 {
6695                                out.push_str("\nSTATUS: [!!] EXPIRED\n");
6696                            } else if days_left < 30 {
6697                                out.push_str(&format!(
6698                                    "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6699                                    days_left
6700                                ));
6701                            } else {
6702                                out.push_str(&format!(
6703                                    "\nSTATUS: Valid ({} days left)\n",
6704                                    days_left
6705                                ));
6706                            }
6707                        } else {
6708                            if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(not_after_raw)
6709                            {
6710                                let now = chrono::Utc::now();
6711                                let days_left = expiry.signed_duration_since(now).num_days();
6712                                if days_left < 0 {
6713                                    out.push_str("\nSTATUS: [!!] EXPIRED\n");
6714                                } else if days_left < 30 {
6715                                    out.push_str(&format!(
6716                                        "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
6717                                        days_left
6718                                    ));
6719                                } else {
6720                                    out.push_str(&format!(
6721                                        "\nSTATUS: Valid ({} days left)\n",
6722                                        days_left
6723                                    ));
6724                                }
6725                            }
6726                        }
6727                    }
6728                }
6729            } else {
6730                out.push_str(&format!("Raw Output: {}\n", text));
6731            }
6732        }
6733    }
6734
6735    #[cfg(not(target_os = "windows"))]
6736    {
6737        out.push_str(
6738            "Note: Deep SSL inspection currently optimized for Windows (PowerShell/SslStream).\n",
6739        );
6740    }
6741
6742    Ok(out)
6743}
6744
6745async fn inspect_data_audit(path: PathBuf, _max_entries: usize) -> Result<String, String> {
6746    let mut out = format!("Host inspection: data_audit (Path: {:?})\n\n", path);
6747
6748    if !path.exists() {
6749        return Err(format!("File not found: {:?}", path));
6750    }
6751    if !path.is_file() {
6752        return Err(format!("Not a file: {:?}", path));
6753    }
6754
6755    let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
6756    out.push_str(&format!(
6757        "File Size: {} bytes ({:.2} MB)\n",
6758        file_size,
6759        file_size as f64 / 1_048_576.0
6760    ));
6761
6762    let ext = path
6763        .extension()
6764        .and_then(|s| s.to_str())
6765        .unwrap_or("")
6766        .to_lowercase();
6767    out.push_str(&format!("Format:    {}\n\n", ext.to_uppercase()));
6768
6769    match ext.as_str() {
6770        "csv" | "tsv" | "txt" | "log" => {
6771            let content = std::fs::read_to_string(&path)
6772                .map_err(|e| format!("Failed to read file: {}", e))?;
6773            let lines: Vec<&str> = content.lines().collect();
6774            out.push_str(&format!("Row Count: {} (total lines)\n", lines.len()));
6775
6776            if let Some(header) = lines.get(0) {
6777                out.push_str("Columns (Guessed from header):\n");
6778                let delimiter = if ext == "tsv" {
6779                    "\t"
6780                } else if header.contains(',') {
6781                    ","
6782                } else {
6783                    " "
6784                };
6785                let cols: Vec<&str> = header.split(delimiter).map(|s| s.trim()).collect();
6786                for (i, col) in cols.iter().enumerate() {
6787                    out.push_str(&format!("  {}. {}\n", i + 1, col));
6788                }
6789            }
6790
6791            out.push_str("\nSample Data (First 5 rows):\n");
6792            for line in lines.iter().take(6) {
6793                out.push_str(&format!("  {}\n", line));
6794            }
6795        }
6796        "json" => {
6797            let content = std::fs::read_to_string(&path)
6798                .map_err(|e| format!("Failed to read file: {}", e))?;
6799            if let Ok(json) = serde_json::from_str::<Value>(&content) {
6800                if let Some(arr) = json.as_array() {
6801                    out.push_str(&format!("Record Count: {}\n", arr.len()));
6802                    if let Some(first) = arr.get(0) {
6803                        if let Some(obj) = first.as_object() {
6804                            out.push_str("Fields (from first record):\n");
6805                            for k in obj.keys() {
6806                                out.push_str(&format!("  - {}\n", k));
6807                            }
6808                        }
6809                    }
6810                    out.push_str("\nSample Record:\n");
6811                    out.push_str(&serde_json::to_string_pretty(&arr.get(0)).unwrap_or_default());
6812                } else if let Some(obj) = json.as_object() {
6813                    out.push_str("Top-level Keys:\n");
6814                    for k in obj.keys() {
6815                        out.push_str(&format!("  - {}\n", k));
6816                    }
6817                }
6818            } else {
6819                out.push_str("Error: Failed to parse as JSON.\n");
6820            }
6821        }
6822        "db" | "sqlite" | "sqlite3" => {
6823            out.push_str("SQLite Database detected.\n");
6824            out.push_str("Use `query_data` to execute SQL against this database.\n");
6825        }
6826        _ => {
6827            out.push_str("Unsupported format for deep audit. Showing first 10 lines:\n\n");
6828            let content = std::fs::read_to_string(&path)
6829                .map_err(|e| format!("Failed to read file: {}", e))?;
6830            for line in content.lines().take(10) {
6831                out.push_str(&format!("  {}\n", line));
6832            }
6833        }
6834    }
6835
6836    Ok(out)
6837}
6838
6839fn inspect_connectivity() -> Result<String, String> {
6840    let mut out = String::from("Host inspection: connectivity\n\n");
6841
6842    #[cfg(target_os = "windows")]
6843    {
6844        let inet_script = r#"
6845try {
6846    $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6847    if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6848} catch { "ERROR:" + $_.Exception.Message }
6849"#;
6850        if let Ok(o) = Command::new("powershell")
6851            .args(["-NoProfile", "-Command", inet_script])
6852            .output()
6853        {
6854            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6855            match text.as_str() {
6856                "REACHABLE" => out.push_str("Internet: reachable\n"),
6857                "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6858                _ => out.push_str(&format!(
6859                    "Internet: {}\n",
6860                    text.trim_start_matches("ERROR:").trim()
6861                )),
6862            }
6863        }
6864
6865        let dns_script = r#"
6866try {
6867    Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6868    "DNS:ok"
6869} catch { "DNS:fail:" + $_.Exception.Message }
6870"#;
6871        if let Ok(o) = Command::new("powershell")
6872            .args(["-NoProfile", "-Command", dns_script])
6873            .output()
6874        {
6875            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6876            if text == "DNS:ok" {
6877                out.push_str("DNS: resolving correctly\n");
6878            } else {
6879                let detail = text.trim_start_matches("DNS:fail:").trim();
6880                out.push_str(&format!("DNS: failed — {}\n", detail));
6881            }
6882        }
6883
6884        let gw_script = r#"
6885(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6886"#;
6887        if let Ok(o) = Command::new("powershell")
6888            .args(["-NoProfile", "-Command", gw_script])
6889            .output()
6890        {
6891            let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6892            if !gw.is_empty() && gw != "0.0.0.0" {
6893                out.push_str(&format!("Default gateway: {}\n", gw));
6894            }
6895        }
6896    }
6897
6898    #[cfg(not(target_os = "windows"))]
6899    {
6900        let reachable = Command::new("ping")
6901            .args(["-c", "1", "-W", "2", "8.8.8.8"])
6902            .output()
6903            .map(|o| o.status.success())
6904            .unwrap_or(false);
6905        out.push_str(if reachable {
6906            "Internet: reachable\n"
6907        } else {
6908            "Internet: unreachable\n"
6909        });
6910        let dns_ok = Command::new("getent")
6911            .args(["hosts", "dns.google"])
6912            .output()
6913            .map(|o| o.status.success())
6914            .unwrap_or(false);
6915        out.push_str(if dns_ok {
6916            "DNS: resolving correctly\n"
6917        } else {
6918            "DNS: failed\n"
6919        });
6920        if let Ok(o) = Command::new("ip")
6921            .args(["route", "show", "default"])
6922            .output()
6923        {
6924            let text = String::from_utf8_lossy(&o.stdout);
6925            if let Some(line) = text.lines().next() {
6926                out.push_str(&format!("Default gateway: {}\n", line.trim()));
6927            }
6928        }
6929    }
6930
6931    Ok(out.trim_end().to_string())
6932}
6933
6934// ── wifi ──────────────────────────────────────────────────────────────────────
6935
6936fn inspect_wifi() -> Result<String, String> {
6937    let mut out = String::from("Host inspection: wifi\n\n");
6938
6939    #[cfg(target_os = "windows")]
6940    {
6941        let output = Command::new("netsh")
6942            .args(["wlan", "show", "interfaces"])
6943            .output()
6944            .map_err(|e| format!("wifi: {e}"))?;
6945        let text = String::from_utf8_lossy(&output.stdout).to_string();
6946
6947        if text.contains("There is no wireless interface") || text.trim().is_empty() {
6948            out.push_str("No wireless interface detected on this machine.\n");
6949            return Ok(out.trim_end().to_string());
6950        }
6951
6952        let fields = [
6953            ("SSID", "SSID"),
6954            ("State", "State"),
6955            ("Signal", "Signal"),
6956            ("Radio type", "Radio type"),
6957            ("Channel", "Channel"),
6958            ("Receive rate (Mbps)", "Download speed (Mbps)"),
6959            ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6960            ("Authentication", "Authentication"),
6961            ("Network type", "Network type"),
6962        ];
6963
6964        let mut any = false;
6965        for line in text.lines() {
6966            let trimmed = line.trim();
6967            for (key, label) in &fields {
6968                if trimmed.starts_with(key) && trimmed.contains(':') {
6969                    let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6970                    if !val.is_empty() {
6971                        out.push_str(&format!("  {label}: {val}\n"));
6972                        any = true;
6973                    }
6974                }
6975            }
6976        }
6977        if !any {
6978            out.push_str("  (Wi-Fi adapter disconnected or no active connection)\n");
6979        }
6980    }
6981
6982    #[cfg(not(target_os = "windows"))]
6983    {
6984        if let Ok(o) = Command::new("nmcli")
6985            .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6986            .output()
6987        {
6988            let text = String::from_utf8_lossy(&o.stdout).to_string();
6989            let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6990            if lines.is_empty() {
6991                out.push_str("No Wi-Fi devices found.\n");
6992            } else {
6993                for l in lines {
6994                    out.push_str(&format!("  {l}\n"));
6995                }
6996            }
6997        } else if let Ok(o) = Command::new("iwconfig").output() {
6998            let text = String::from_utf8_lossy(&o.stdout).to_string();
6999            if !text.trim().is_empty() {
7000                out.push_str(text.trim());
7001                out.push('\n');
7002            }
7003        } else {
7004            out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
7005        }
7006    }
7007
7008    Ok(out.trim_end().to_string())
7009}
7010
7011// ── connections ───────────────────────────────────────────────────────────────
7012
7013fn inspect_connections(max_entries: usize) -> Result<String, String> {
7014    let mut out = String::from("Host inspection: connections\n\n");
7015    let n = max_entries.clamp(1, 25);
7016
7017    #[cfg(target_os = "windows")]
7018    {
7019        let script = format!(
7020            r#"
7021try {{
7022    $procs = @{{}}
7023    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
7024    $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
7025        Sort-Object OwningProcess
7026    "TOTAL:" + $all.Count
7027    $all | Select-Object -First {n} | ForEach-Object {{
7028        $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
7029        $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
7030    }}
7031}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7032        );
7033
7034        let output = Command::new("powershell")
7035            .args(["-NoProfile", "-Command", &script])
7036            .output()
7037            .map_err(|e| format!("connections: {e}"))?;
7038
7039        let raw = String::from_utf8_lossy(&output.stdout);
7040        let text = raw.trim();
7041
7042        if text.starts_with("ERROR:") {
7043            out.push_str(&format!("Unable to query connections: {text}\n"));
7044        } else {
7045            let mut total = 0usize;
7046            let mut rows = Vec::new();
7047            for line in text.lines() {
7048                if let Some(rest) = line.strip_prefix("TOTAL:") {
7049                    total = rest.trim().parse().unwrap_or(0);
7050                } else {
7051                    rows.push(line);
7052                }
7053            }
7054            out.push_str(&format!("Established TCP connections: {total}\n\n"));
7055            for row in &rows {
7056                let parts: Vec<&str> = row.splitn(4, '|').collect();
7057                if parts.len() == 4 {
7058                    out.push_str(&format!(
7059                        "  {:<15} (pid {:<5}) | {} → {}\n",
7060                        parts[0], parts[1], parts[2], parts[3]
7061                    ));
7062                }
7063            }
7064            if total > n {
7065                out.push_str(&format!(
7066                    "\n  ... {} more connections not shown\n",
7067                    total.saturating_sub(n)
7068                ));
7069            }
7070        }
7071    }
7072
7073    #[cfg(not(target_os = "windows"))]
7074    {
7075        if let Ok(o) = Command::new("ss")
7076            .args(["-tnp", "state", "established"])
7077            .output()
7078        {
7079            let text = String::from_utf8_lossy(&o.stdout);
7080            let lines: Vec<&str> = text
7081                .lines()
7082                .skip(1)
7083                .filter(|l| !l.trim().is_empty())
7084                .collect();
7085            out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
7086            for line in lines.iter().take(n) {
7087                out.push_str(&format!("  {}\n", line.trim()));
7088            }
7089            if lines.len() > n {
7090                out.push_str(&format!("\n  ... {} more not shown\n", lines.len() - n));
7091            }
7092        } else {
7093            out.push_str("ss not available — install iproute2\n");
7094        }
7095    }
7096
7097    Ok(out.trim_end().to_string())
7098}
7099
7100// ── vpn ───────────────────────────────────────────────────────────────────────
7101
7102fn inspect_vpn() -> Result<String, String> {
7103    let mut out = String::from("Host inspection: vpn\n\n");
7104
7105    #[cfg(target_os = "windows")]
7106    {
7107        let script = r#"
7108try {
7109    $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
7110        $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
7111        $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
7112    }
7113    if ($vpn) {
7114        foreach ($a in $vpn) {
7115            $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
7116        }
7117    } else { "NONE" }
7118} catch { "ERROR:" + $_.Exception.Message }
7119"#;
7120        let output = Command::new("powershell")
7121            .args(["-NoProfile", "-Command", script])
7122            .output()
7123            .map_err(|e| format!("vpn: {e}"))?;
7124
7125        let raw = String::from_utf8_lossy(&output.stdout);
7126        let text = raw.trim();
7127
7128        if text == "NONE" {
7129            out.push_str("No VPN adapters detected — no active VPN connection found.\n");
7130        } else if text.starts_with("ERROR:") {
7131            out.push_str(&format!("Unable to query adapters: {text}\n"));
7132        } else {
7133            out.push_str("VPN adapters:\n\n");
7134            for line in text.lines() {
7135                let parts: Vec<&str> = line.splitn(4, '|').collect();
7136                if parts.len() >= 3 {
7137                    let name = parts[0];
7138                    let desc = parts[1];
7139                    let status = parts[2];
7140                    let media = parts.get(3).unwrap_or(&"unknown");
7141                    let label = if status.trim() == "Up" {
7142                        "CONNECTED"
7143                    } else {
7144                        "disconnected"
7145                    };
7146                    out.push_str(&format!(
7147                        "  {name} [{label}]\n    {desc}\n    Status: {status} | Media: {media}\n\n"
7148                    ));
7149                }
7150            }
7151        }
7152
7153        // Windows built-in VPN connections
7154        let ras_script = r#"
7155try {
7156    $c = Get-VpnConnection -ErrorAction Stop
7157    if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
7158    else { "NO_RAS" }
7159} catch { "NO_RAS" }
7160"#;
7161        if let Ok(o) = Command::new("powershell")
7162            .args(["-NoProfile", "-Command", ras_script])
7163            .output()
7164        {
7165            let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
7166            if t != "NO_RAS" && !t.is_empty() {
7167                out.push_str("Windows VPN connections:\n");
7168                for line in t.lines() {
7169                    let parts: Vec<&str> = line.splitn(3, '|').collect();
7170                    if parts.len() >= 2 {
7171                        let name = parts[0];
7172                        let status = parts[1];
7173                        let server = parts.get(2).unwrap_or(&"");
7174                        out.push_str(&format!("  {name} → {server} [{status}]\n"));
7175                    }
7176                }
7177            }
7178        }
7179    }
7180
7181    #[cfg(not(target_os = "windows"))]
7182    {
7183        if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
7184            let text = String::from_utf8_lossy(&o.stdout);
7185            let vpn_ifaces: Vec<&str> = text
7186                .lines()
7187                .filter(|l| {
7188                    l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
7189                })
7190                .collect();
7191            if vpn_ifaces.is_empty() {
7192                out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
7193            } else {
7194                out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
7195                for l in vpn_ifaces {
7196                    out.push_str(&format!("  {}\n", l.trim()));
7197                }
7198            }
7199        }
7200    }
7201
7202    Ok(out.trim_end().to_string())
7203}
7204
7205// ── proxy ─────────────────────────────────────────────────────────────────────
7206
7207fn inspect_proxy() -> Result<String, String> {
7208    let mut out = String::from("Host inspection: proxy\n\n");
7209
7210    #[cfg(target_os = "windows")]
7211    {
7212        let script = r#"
7213$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
7214if ($ie) {
7215    "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
7216} else { "NONE" }
7217"#;
7218        if let Ok(o) = Command::new("powershell")
7219            .args(["-NoProfile", "-Command", script])
7220            .output()
7221        {
7222            let raw = String::from_utf8_lossy(&o.stdout);
7223            let text = raw.trim();
7224            if text != "NONE" && !text.is_empty() {
7225                let get = |key: &str| -> &str {
7226                    text.split('|')
7227                        .find(|s| s.starts_with(key))
7228                        .and_then(|s| s.splitn(2, ':').nth(1))
7229                        .unwrap_or("")
7230                };
7231                let enabled = get("ENABLE");
7232                let server = get("SERVER");
7233                let overrides = get("OVERRIDE");
7234                out.push_str("WinINET / IE proxy:\n");
7235                out.push_str(&format!(
7236                    "  Enabled: {}\n",
7237                    if enabled == "1" { "yes" } else { "no" }
7238                ));
7239                if !server.is_empty() && server != "None" {
7240                    out.push_str(&format!("  Proxy server: {server}\n"));
7241                }
7242                if !overrides.is_empty() && overrides != "None" {
7243                    out.push_str(&format!("  Bypass list: {overrides}\n"));
7244                }
7245                out.push('\n');
7246            }
7247        }
7248
7249        if let Ok(o) = Command::new("netsh")
7250            .args(["winhttp", "show", "proxy"])
7251            .output()
7252        {
7253            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7254            out.push_str("WinHTTP proxy:\n");
7255            for line in text.lines() {
7256                let l = line.trim();
7257                if !l.is_empty() {
7258                    out.push_str(&format!("  {l}\n"));
7259                }
7260            }
7261            out.push('\n');
7262        }
7263
7264        let mut env_found = false;
7265        for var in &[
7266            "http_proxy",
7267            "https_proxy",
7268            "HTTP_PROXY",
7269            "HTTPS_PROXY",
7270            "no_proxy",
7271            "NO_PROXY",
7272        ] {
7273            if let Ok(val) = std::env::var(var) {
7274                if !env_found {
7275                    out.push_str("Environment proxy variables:\n");
7276                    env_found = true;
7277                }
7278                out.push_str(&format!("  {var}: {val}\n"));
7279            }
7280        }
7281        if !env_found {
7282            out.push_str("No proxy environment variables set.\n");
7283        }
7284    }
7285
7286    #[cfg(not(target_os = "windows"))]
7287    {
7288        let mut found = false;
7289        for var in &[
7290            "http_proxy",
7291            "https_proxy",
7292            "HTTP_PROXY",
7293            "HTTPS_PROXY",
7294            "no_proxy",
7295            "NO_PROXY",
7296            "ALL_PROXY",
7297            "all_proxy",
7298        ] {
7299            if let Ok(val) = std::env::var(var) {
7300                if !found {
7301                    out.push_str("Proxy environment variables:\n");
7302                    found = true;
7303                }
7304                out.push_str(&format!("  {var}: {val}\n"));
7305            }
7306        }
7307        if !found {
7308            out.push_str("No proxy environment variables set.\n");
7309        }
7310        if let Ok(content) = std::fs::read_to_string("/etc/environment") {
7311            let proxy_lines: Vec<&str> = content
7312                .lines()
7313                .filter(|l| l.to_lowercase().contains("proxy"))
7314                .collect();
7315            if !proxy_lines.is_empty() {
7316                out.push_str("\nSystem proxy (/etc/environment):\n");
7317                for l in proxy_lines {
7318                    out.push_str(&format!("  {l}\n"));
7319                }
7320            }
7321        }
7322    }
7323
7324    Ok(out.trim_end().to_string())
7325}
7326
7327// ── firewall_rules ────────────────────────────────────────────────────────────
7328
7329fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
7330    let mut out = String::from("Host inspection: firewall_rules\n\n");
7331    let n = max_entries.clamp(1, 20);
7332
7333    #[cfg(target_os = "windows")]
7334    {
7335        let script = format!(
7336            r#"
7337try {{
7338    $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
7339        Where-Object {{
7340            $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
7341            $_.Owner -eq $null
7342        }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
7343    "TOTAL:" + $rules.Count
7344    $rules | ForEach-Object {{
7345        $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
7346        $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
7347        $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
7348    }}
7349}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7350        );
7351
7352        let output = Command::new("powershell")
7353            .args(["-NoProfile", "-Command", &script])
7354            .output()
7355            .map_err(|e| format!("firewall_rules: {e}"))?;
7356
7357        let raw = String::from_utf8_lossy(&output.stdout);
7358        let text = raw.trim();
7359
7360        if text.starts_with("ERROR:") {
7361            out.push_str(&format!(
7362                "Unable to query firewall rules: {}\n",
7363                text.trim_start_matches("ERROR:").trim()
7364            ));
7365            out.push_str("This query may require running as administrator.\n");
7366        } else if text.is_empty() {
7367            out.push_str("No non-default enabled firewall rules found.\n");
7368        } else {
7369            let mut total = 0usize;
7370            for line in text.lines() {
7371                if let Some(rest) = line.strip_prefix("TOTAL:") {
7372                    total = rest.trim().parse().unwrap_or(0);
7373                    out.push_str(&format!(
7374                        "Non-default enabled rules (showing up to {n}):\n\n"
7375                    ));
7376                } else {
7377                    let parts: Vec<&str> = line.splitn(4, '|').collect();
7378                    if parts.len() >= 3 {
7379                        let name = parts[0];
7380                        let dir = parts[1];
7381                        let action = parts[2];
7382                        let profile = parts.get(3).unwrap_or(&"Any");
7383                        let icon = if action == "Block" { "[!]" } else { "   " };
7384                        out.push_str(&format!(
7385                            "  {icon} [{dir}] {action}: {name} (profile: {profile})\n"
7386                        ));
7387                    }
7388                }
7389            }
7390            if total == 0 {
7391                out.push_str("No non-default enabled rules found.\n");
7392            }
7393        }
7394    }
7395
7396    #[cfg(not(target_os = "windows"))]
7397    {
7398        if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
7399            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7400            if !text.is_empty() {
7401                out.push_str(&text);
7402                out.push('\n');
7403            }
7404        } else if let Ok(o) = Command::new("iptables")
7405            .args(["-L", "-n", "--line-numbers"])
7406            .output()
7407        {
7408            let text = String::from_utf8_lossy(&o.stdout);
7409            for l in text.lines().take(n * 2) {
7410                out.push_str(&format!("  {l}\n"));
7411            }
7412        } else {
7413            out.push_str("ufw and iptables not available or insufficient permissions.\n");
7414        }
7415    }
7416
7417    Ok(out.trim_end().to_string())
7418}
7419
7420// ── traceroute ────────────────────────────────────────────────────────────────
7421
7422fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
7423    let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
7424    let hops = max_entries.clamp(5, 30);
7425
7426    #[cfg(target_os = "windows")]
7427    {
7428        let output = Command::new("tracert")
7429            .args(["-d", "-h", &hops.to_string(), host])
7430            .output()
7431            .map_err(|e| format!("tracert: {e}"))?;
7432        let raw = String::from_utf8_lossy(&output.stdout);
7433        let mut hop_count = 0usize;
7434        for line in raw.lines() {
7435            let trimmed = line.trim();
7436            if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
7437                hop_count += 1;
7438                out.push_str(&format!("  {trimmed}\n"));
7439            } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
7440                out.push_str(&format!("{trimmed}\n"));
7441            }
7442        }
7443        if hop_count == 0 {
7444            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7445        }
7446    }
7447
7448    #[cfg(not(target_os = "windows"))]
7449    {
7450        let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
7451            || std::path::Path::new("/usr/sbin/traceroute").exists()
7452        {
7453            "traceroute"
7454        } else {
7455            "tracepath"
7456        };
7457        let output = Command::new(cmd)
7458            .args(["-m", &hops.to_string(), "-n", host])
7459            .output()
7460            .map_err(|e| format!("{cmd}: {e}"))?;
7461        let raw = String::from_utf8_lossy(&output.stdout);
7462        let mut hop_count = 0usize;
7463        for line in raw.lines().take(hops + 2) {
7464            let trimmed = line.trim();
7465            if !trimmed.is_empty() {
7466                hop_count += 1;
7467                out.push_str(&format!("  {trimmed}\n"));
7468            }
7469        }
7470        if hop_count == 0 {
7471            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7472        }
7473    }
7474
7475    Ok(out.trim_end().to_string())
7476}
7477
7478// ── dns_cache ─────────────────────────────────────────────────────────────────
7479
7480fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
7481    let mut out = String::from("Host inspection: dns_cache\n\n");
7482    let n = max_entries.clamp(10, 100);
7483
7484    #[cfg(target_os = "windows")]
7485    {
7486        let output = Command::new("powershell")
7487            .args([
7488                "-NoProfile",
7489                "-Command",
7490                "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
7491            ])
7492            .output()
7493            .map_err(|e| format!("dns_cache: {e}"))?;
7494
7495        let raw = String::from_utf8_lossy(&output.stdout);
7496        let lines: Vec<&str> = raw.lines().skip(1).collect();
7497        let total = lines.len();
7498
7499        if total == 0 {
7500            out.push_str("DNS cache is empty or could not be read.\n");
7501        } else {
7502            out.push_str(&format!(
7503                "DNS cache entries (showing up to {n} of {total}):\n\n"
7504            ));
7505            let mut shown = 0usize;
7506            for line in lines.iter().take(n) {
7507                let cols: Vec<&str> = line.splitn(4, ',').collect();
7508                if cols.len() >= 3 {
7509                    let entry = cols[0].trim_matches('"');
7510                    let rtype = cols[1].trim_matches('"');
7511                    let data = cols[2].trim_matches('"');
7512                    let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
7513                    out.push_str(&format!("  {entry:<45} {rtype:<6} {data}  (TTL {ttl}s)\n"));
7514                    shown += 1;
7515                }
7516            }
7517            if total > shown {
7518                out.push_str(&format!("\n  ... and {} more entries\n", total - shown));
7519            }
7520        }
7521    }
7522
7523    #[cfg(not(target_os = "windows"))]
7524    {
7525        if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
7526            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7527            if !text.is_empty() {
7528                out.push_str("systemd-resolved statistics:\n");
7529                for line in text.lines().take(n) {
7530                    out.push_str(&format!("  {line}\n"));
7531                }
7532                out.push('\n');
7533            }
7534        }
7535        if let Ok(o) = Command::new("dscacheutil")
7536            .args(["-cachedump", "-entries", "Host"])
7537            .output()
7538        {
7539            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7540            if !text.is_empty() {
7541                out.push_str("DNS cache (macOS dscacheutil):\n");
7542                for line in text.lines().take(n) {
7543                    out.push_str(&format!("  {line}\n"));
7544                }
7545            } else {
7546                out.push_str("DNS cache is empty or not accessible on this platform.\n");
7547            }
7548        } else {
7549            out.push_str(
7550                "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
7551            );
7552        }
7553    }
7554
7555    Ok(out.trim_end().to_string())
7556}
7557
7558// ── arp ───────────────────────────────────────────────────────────────────────
7559
7560fn inspect_arp() -> Result<String, String> {
7561    let mut out = String::from("Host inspection: arp\n\n");
7562
7563    #[cfg(target_os = "windows")]
7564    {
7565        let output = Command::new("arp")
7566            .args(["-a"])
7567            .output()
7568            .map_err(|e| format!("arp: {e}"))?;
7569        let raw = String::from_utf8_lossy(&output.stdout);
7570        let mut count = 0usize;
7571        for line in raw.lines() {
7572            let t = line.trim();
7573            if t.is_empty() {
7574                continue;
7575            }
7576            out.push_str(&format!("  {t}\n"));
7577            if t.contains("dynamic") || t.contains("static") {
7578                count += 1;
7579            }
7580        }
7581        out.push_str(&format!("\nTotal entries: {count}\n"));
7582    }
7583
7584    #[cfg(not(target_os = "windows"))]
7585    {
7586        if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7587            let raw = String::from_utf8_lossy(&o.stdout);
7588            let mut count = 0usize;
7589            for line in raw.lines() {
7590                let t = line.trim();
7591                if !t.is_empty() {
7592                    out.push_str(&format!("  {t}\n"));
7593                    count += 1;
7594                }
7595            }
7596            out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
7597        } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7598            let raw = String::from_utf8_lossy(&o.stdout);
7599            let mut count = 0usize;
7600            for line in raw.lines() {
7601                let t = line.trim();
7602                if !t.is_empty() {
7603                    out.push_str(&format!("  {t}\n"));
7604                    count += 1;
7605                }
7606            }
7607            out.push_str(&format!("\nTotal entries: {count}\n"));
7608        } else {
7609            out.push_str("arp and ip neigh not available.\n");
7610        }
7611    }
7612
7613    Ok(out.trim_end().to_string())
7614}
7615
7616// ── route_table ───────────────────────────────────────────────────────────────
7617
7618fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7619    let mut out = String::from("Host inspection: route_table\n\n");
7620    let n = max_entries.clamp(10, 50);
7621
7622    #[cfg(target_os = "windows")]
7623    {
7624        let script = r#"
7625try {
7626    $routes = Get-NetRoute -ErrorAction Stop |
7627        Where-Object { $_.RouteMetric -lt 9000 } |
7628        Sort-Object RouteMetric |
7629        Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7630    "TOTAL:" + $routes.Count
7631    $routes | ForEach-Object {
7632        $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7633    }
7634} catch { "ERROR:" + $_.Exception.Message }
7635"#;
7636        let output = Command::new("powershell")
7637            .args(["-NoProfile", "-Command", script])
7638            .output()
7639            .map_err(|e| format!("route_table: {e}"))?;
7640        let raw = String::from_utf8_lossy(&output.stdout);
7641        let text = raw.trim();
7642
7643        if text.starts_with("ERROR:") {
7644            out.push_str(&format!(
7645                "Unable to read route table: {}\n",
7646                text.trim_start_matches("ERROR:").trim()
7647            ));
7648        } else {
7649            let mut shown = 0usize;
7650            for line in text.lines() {
7651                if let Some(rest) = line.strip_prefix("TOTAL:") {
7652                    let total: usize = rest.trim().parse().unwrap_or(0);
7653                    out.push_str(&format!(
7654                        "Routing table (showing up to {n} of {total} routes):\n\n"
7655                    ));
7656                    out.push_str(&format!(
7657                        "  {:<22} {:<18} {:>8}  Interface\n",
7658                        "Destination", "Next Hop", "Metric"
7659                    ));
7660                    out.push_str(&format!("  {}\n", "-".repeat(70)));
7661                } else if shown < n {
7662                    let parts: Vec<&str> = line.splitn(4, '|').collect();
7663                    if parts.len() == 4 {
7664                        let dest = parts[0];
7665                        let hop =
7666                            if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
7667                                "on-link"
7668                            } else {
7669                                parts[1]
7670                            };
7671                        let metric = parts[2];
7672                        let iface = parts[3];
7673                        out.push_str(&format!("  {dest:<22} {hop:<18} {metric:>8}  {iface}\n"));
7674                        shown += 1;
7675                    }
7676                }
7677            }
7678        }
7679    }
7680
7681    #[cfg(not(target_os = "windows"))]
7682    {
7683        if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7684            let raw = String::from_utf8_lossy(&o.stdout);
7685            let lines: Vec<&str> = raw.lines().collect();
7686            let total = lines.len();
7687            out.push_str(&format!(
7688                "Routing table (showing up to {n} of {total} routes):\n\n"
7689            ));
7690            for line in lines.iter().take(n) {
7691                out.push_str(&format!("  {line}\n"));
7692            }
7693            if total > n {
7694                out.push_str(&format!("\n  ... and {} more routes\n", total - n));
7695            }
7696        } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7697            let raw = String::from_utf8_lossy(&o.stdout);
7698            for line in raw.lines().take(n) {
7699                out.push_str(&format!("  {line}\n"));
7700            }
7701        } else {
7702            out.push_str("ip route and netstat not available.\n");
7703        }
7704    }
7705
7706    Ok(out.trim_end().to_string())
7707}
7708
7709// ── env ───────────────────────────────────────────────────────────────────────
7710
7711fn inspect_env(max_entries: usize) -> Result<String, String> {
7712    let mut out = String::from("Host inspection: env\n\n");
7713    let n = max_entries.clamp(10, 50);
7714
7715    fn looks_like_secret(name: &str) -> bool {
7716        let n = name.to_uppercase();
7717        n.contains("KEY")
7718            || n.contains("SECRET")
7719            || n.contains("TOKEN")
7720            || n.contains("PASSWORD")
7721            || n.contains("PASSWD")
7722            || n.contains("CREDENTIAL")
7723            || n.contains("AUTH")
7724            || n.contains("CERT")
7725            || n.contains("PRIVATE")
7726    }
7727
7728    let known_dev_vars: &[&str] = &[
7729        "CARGO_HOME",
7730        "RUSTUP_HOME",
7731        "GOPATH",
7732        "GOROOT",
7733        "GOBIN",
7734        "JAVA_HOME",
7735        "ANDROID_HOME",
7736        "ANDROID_SDK_ROOT",
7737        "PYTHONPATH",
7738        "PYTHONHOME",
7739        "VIRTUAL_ENV",
7740        "CONDA_DEFAULT_ENV",
7741        "CONDA_PREFIX",
7742        "NODE_PATH",
7743        "NVM_DIR",
7744        "NVM_BIN",
7745        "PNPM_HOME",
7746        "DENO_INSTALL",
7747        "DENO_DIR",
7748        "DOTNET_ROOT",
7749        "NUGET_PACKAGES",
7750        "CMAKE_HOME",
7751        "VCPKG_ROOT",
7752        "AWS_PROFILE",
7753        "AWS_REGION",
7754        "AWS_DEFAULT_REGION",
7755        "GCP_PROJECT",
7756        "GOOGLE_CLOUD_PROJECT",
7757        "GOOGLE_APPLICATION_CREDENTIALS",
7758        "AZURE_SUBSCRIPTION_ID",
7759        "DATABASE_URL",
7760        "REDIS_URL",
7761        "MONGO_URI",
7762        "EDITOR",
7763        "VISUAL",
7764        "SHELL",
7765        "TERM",
7766        "XDG_CONFIG_HOME",
7767        "XDG_DATA_HOME",
7768        "XDG_CACHE_HOME",
7769        "HOME",
7770        "USERPROFILE",
7771        "APPDATA",
7772        "LOCALAPPDATA",
7773        "TEMP",
7774        "TMP",
7775        "COMPUTERNAME",
7776        "USERNAME",
7777        "USERDOMAIN",
7778        "PROCESSOR_ARCHITECTURE",
7779        "NUMBER_OF_PROCESSORS",
7780        "OS",
7781        "HOMEDRIVE",
7782        "HOMEPATH",
7783        "HTTP_PROXY",
7784        "HTTPS_PROXY",
7785        "NO_PROXY",
7786        "ALL_PROXY",
7787        "http_proxy",
7788        "https_proxy",
7789        "no_proxy",
7790        "DOCKER_HOST",
7791        "DOCKER_BUILDKIT",
7792        "COMPOSE_PROJECT_NAME",
7793        "KUBECONFIG",
7794        "KUBE_CONTEXT",
7795        "CI",
7796        "GITHUB_ACTIONS",
7797        "GITLAB_CI",
7798        "LMSTUDIO_HOME",
7799        "HEMATITE_URL",
7800    ];
7801
7802    let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7803    all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7804    let total = all_vars.len();
7805
7806    let mut dev_found: Vec<String> = Vec::new();
7807    let mut secret_found: Vec<String> = Vec::new();
7808
7809    for (k, v) in &all_vars {
7810        if k == "PATH" {
7811            continue;
7812        }
7813        if looks_like_secret(k) {
7814            secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7815        } else {
7816            let k_upper = k.to_uppercase();
7817            let is_known = known_dev_vars
7818                .iter()
7819                .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7820            if is_known {
7821                let display = if v.len() > 120 {
7822                    format!("{k} = {}…", &v[..117])
7823                } else {
7824                    format!("{k} = {v}")
7825                };
7826                dev_found.push(display);
7827            }
7828        }
7829    }
7830
7831    out.push_str(&format!("Total environment variables: {total}\n\n"));
7832
7833    if let Ok(p) = std::env::var("PATH") {
7834        let sep = if cfg!(target_os = "windows") {
7835            ';'
7836        } else {
7837            ':'
7838        };
7839        let count = p.split(sep).count();
7840        out.push_str(&format!(
7841            "PATH: {count} entries (use topic=path for full audit)\n\n"
7842        ));
7843    }
7844
7845    if !secret_found.is_empty() {
7846        out.push_str(&format!(
7847            "=== Secret/credential variables ({} detected, values hidden) ===\n",
7848            secret_found.len()
7849        ));
7850        for s in secret_found.iter().take(n) {
7851            out.push_str(&format!("  {s}\n"));
7852        }
7853        out.push('\n');
7854    }
7855
7856    if !dev_found.is_empty() {
7857        out.push_str(&format!(
7858            "=== Developer & tool variables ({}) ===\n",
7859            dev_found.len()
7860        ));
7861        for d in dev_found.iter().take(n) {
7862            out.push_str(&format!("  {d}\n"));
7863        }
7864        out.push('\n');
7865    }
7866
7867    let other_count = all_vars
7868        .iter()
7869        .filter(|(k, _)| {
7870            k != "PATH"
7871                && !looks_like_secret(k)
7872                && !known_dev_vars
7873                    .iter()
7874                    .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7875        })
7876        .count();
7877    if other_count > 0 {
7878        out.push_str(&format!(
7879            "Other variables: {other_count} (use 'env' in shell to see all)\n"
7880        ));
7881    }
7882
7883    Ok(out.trim_end().to_string())
7884}
7885
7886// ── hosts_file ────────────────────────────────────────────────────────────────
7887
7888fn inspect_hosts_file() -> Result<String, String> {
7889    let mut out = String::from("Host inspection: hosts_file\n\n");
7890
7891    let hosts_path = if cfg!(target_os = "windows") {
7892        std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7893    } else {
7894        std::path::PathBuf::from("/etc/hosts")
7895    };
7896
7897    out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7898
7899    match fs::read_to_string(&hosts_path) {
7900        Ok(content) => {
7901            let mut active_entries: Vec<String> = Vec::new();
7902            let mut comment_lines = 0usize;
7903            let mut blank_lines = 0usize;
7904
7905            for line in content.lines() {
7906                let t = line.trim();
7907                if t.is_empty() {
7908                    blank_lines += 1;
7909                } else if t.starts_with('#') {
7910                    comment_lines += 1;
7911                } else {
7912                    active_entries.push(line.to_string());
7913                }
7914            }
7915
7916            out.push_str(&format!(
7917                "Active entries: {}  |  Comment lines: {}  |  Blank lines: {}\n\n",
7918                active_entries.len(),
7919                comment_lines,
7920                blank_lines
7921            ));
7922
7923            if active_entries.is_empty() {
7924                out.push_str(
7925                    "No active host entries (file contains only comments/blanks — standard default state).\n",
7926                );
7927            } else {
7928                out.push_str("=== Active entries ===\n");
7929                for entry in &active_entries {
7930                    out.push_str(&format!("  {entry}\n"));
7931                }
7932                out.push('\n');
7933
7934                let custom: Vec<&String> = active_entries
7935                    .iter()
7936                    .filter(|e| {
7937                        let t = e.trim_start();
7938                        !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7939                    })
7940                    .collect();
7941                if !custom.is_empty() {
7942                    out.push_str(&format!(
7943                        "[!] Custom (non-loopback) entries: {}\n",
7944                        custom.len()
7945                    ));
7946                    for e in &custom {
7947                        out.push_str(&format!("  {e}\n"));
7948                    }
7949                } else {
7950                    out.push_str("All active entries are standard loopback or block entries.\n");
7951                }
7952            }
7953
7954            out.push_str("\n=== Full file ===\n");
7955            for line in content.lines() {
7956                out.push_str(&format!("  {line}\n"));
7957            }
7958        }
7959        Err(e) => {
7960            out.push_str(&format!("Could not read hosts file: {e}\n"));
7961            if cfg!(target_os = "windows") {
7962                out.push_str(
7963                    "On Windows, run Hematite as Administrator if permission is denied.\n",
7964                );
7965            }
7966        }
7967    }
7968
7969    Ok(out.trim_end().to_string())
7970}
7971
7972// ── docker ────────────────────────────────────────────────────────────────────
7973
7974struct AuditFinding {
7975    finding: String,
7976    impact: String,
7977    fix: String,
7978}
7979
7980#[cfg(target_os = "windows")]
7981#[derive(Debug, Clone)]
7982struct WindowsPnpDevice {
7983    name: String,
7984    status: String,
7985    problem: Option<u64>,
7986    class_name: Option<String>,
7987    instance_id: Option<String>,
7988}
7989
7990#[cfg(target_os = "windows")]
7991#[derive(Debug, Clone)]
7992struct WindowsSoundDevice {
7993    name: String,
7994    status: String,
7995    manufacturer: Option<String>,
7996}
7997
7998struct DockerMountAudit {
7999    mount_type: String,
8000    source: Option<String>,
8001    destination: String,
8002    name: Option<String>,
8003    read_write: Option<bool>,
8004    driver: Option<String>,
8005    exists_on_host: Option<bool>,
8006}
8007
8008struct DockerContainerAudit {
8009    name: String,
8010    image: String,
8011    status: String,
8012    mounts: Vec<DockerMountAudit>,
8013}
8014
8015struct DockerVolumeAudit {
8016    name: String,
8017    driver: String,
8018    mountpoint: Option<String>,
8019    scope: Option<String>,
8020}
8021
8022#[cfg(target_os = "windows")]
8023struct WslDistroAudit {
8024    name: String,
8025    state: String,
8026    version: String,
8027}
8028
8029#[cfg(target_os = "windows")]
8030struct WslRootUsage {
8031    total_kb: u64,
8032    used_kb: u64,
8033    avail_kb: u64,
8034    use_percent: String,
8035    mnt_c_present: Option<bool>,
8036}
8037
8038fn docker_engine_version() -> Result<String, String> {
8039    let version_output = Command::new("docker")
8040        .args(["version", "--format", "{{.Server.Version}}"])
8041        .output();
8042
8043    match version_output {
8044        Err(_) => Err(
8045            "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
8046        ),
8047        Ok(o) if !o.status.success() => {
8048            let stderr = String::from_utf8_lossy(&o.stderr);
8049            if stderr.contains("cannot connect")
8050                || stderr.contains("Is the docker daemon running")
8051                || stderr.contains("pipe")
8052                || stderr.contains("socket")
8053            {
8054                Err(
8055                    "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
8056                )
8057            } else {
8058                Err(format!("Docker: error - {}", stderr.trim()))
8059            }
8060        }
8061        Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
8062    }
8063}
8064
8065fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
8066    let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
8067        return Vec::new();
8068    };
8069    let Value::Array(entries) = value else {
8070        return Vec::new();
8071    };
8072
8073    let mut mounts = Vec::new();
8074    for entry in entries {
8075        let mount_type = entry
8076            .get("Type")
8077            .and_then(|v| v.as_str())
8078            .unwrap_or("unknown")
8079            .to_string();
8080        let source = entry
8081            .get("Source")
8082            .and_then(|v| v.as_str())
8083            .map(|v| v.to_string());
8084        let destination = entry
8085            .get("Destination")
8086            .and_then(|v| v.as_str())
8087            .unwrap_or("?")
8088            .to_string();
8089        let name = entry
8090            .get("Name")
8091            .and_then(|v| v.as_str())
8092            .map(|v| v.to_string());
8093        let read_write = entry.get("RW").and_then(|v| v.as_bool());
8094        let driver = entry
8095            .get("Driver")
8096            .and_then(|v| v.as_str())
8097            .map(|v| v.to_string());
8098        let exists_on_host = if mount_type == "bind" {
8099            source.as_deref().map(|path| Path::new(path).exists())
8100        } else {
8101            None
8102        };
8103        mounts.push(DockerMountAudit {
8104            mount_type,
8105            source,
8106            destination,
8107            name,
8108            read_write,
8109            driver,
8110            exists_on_host,
8111        });
8112    }
8113
8114    mounts
8115}
8116
8117fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
8118    let mut audit = DockerVolumeAudit {
8119        name: name.to_string(),
8120        driver: "unknown".to_string(),
8121        mountpoint: None,
8122        scope: None,
8123    };
8124
8125    if let Ok(output) = Command::new("docker")
8126        .args(["volume", "inspect", name, "--format", "{{json .}}"])
8127        .output()
8128    {
8129        if output.status.success() {
8130            if let Ok(value) =
8131                serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
8132            {
8133                audit.driver = value
8134                    .get("Driver")
8135                    .and_then(|v| v.as_str())
8136                    .unwrap_or("unknown")
8137                    .to_string();
8138                audit.mountpoint = value
8139                    .get("Mountpoint")
8140                    .and_then(|v| v.as_str())
8141                    .map(|v| v.to_string());
8142                audit.scope = value
8143                    .get("Scope")
8144                    .and_then(|v| v.as_str())
8145                    .map(|v| v.to_string());
8146            }
8147        }
8148    }
8149
8150    audit
8151}
8152
8153#[cfg(target_os = "windows")]
8154fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
8155    let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
8156    for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
8157        let path = local_app_data
8158            .join("Docker")
8159            .join("wsl")
8160            .join("disk")
8161            .join(file_name);
8162        if let Ok(metadata) = fs::metadata(&path) {
8163            return Some((path, metadata.len()));
8164        }
8165    }
8166    None
8167}
8168
8169#[cfg(target_os = "windows")]
8170fn clean_wsl_text(raw: &[u8]) -> String {
8171    String::from_utf8_lossy(raw)
8172        .chars()
8173        .filter(|c| *c != '\0')
8174        .collect()
8175}
8176
8177#[cfg(target_os = "windows")]
8178fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
8179    let mut distros = Vec::new();
8180    for line in raw.lines() {
8181        let trimmed = line.trim();
8182        if trimmed.is_empty()
8183            || trimmed.to_uppercase().starts_with("NAME")
8184            || trimmed.starts_with("---")
8185        {
8186            continue;
8187        }
8188        let normalized = trimmed.trim_start_matches('*').trim();
8189        let cols: Vec<&str> = normalized.split_whitespace().collect();
8190        if cols.len() < 3 {
8191            continue;
8192        }
8193        let version = cols[cols.len() - 1].to_string();
8194        let state = cols[cols.len() - 2].to_string();
8195        let name = cols[..cols.len() - 2].join(" ");
8196        if !name.is_empty() {
8197            distros.push(WslDistroAudit {
8198                name,
8199                state,
8200                version,
8201            });
8202        }
8203    }
8204    distros
8205}
8206
8207#[cfg(target_os = "windows")]
8208fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
8209    let output = Command::new("wsl")
8210        .args([
8211            "-d",
8212            distro_name,
8213            "--",
8214            "sh",
8215            "-lc",
8216            "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
8217        ])
8218        .output()
8219        .ok()?;
8220    if !output.status.success() {
8221        return None;
8222    }
8223
8224    let text = clean_wsl_text(&output.stdout);
8225    let mut total_kb = 0;
8226    let mut used_kb = 0;
8227    let mut avail_kb = 0;
8228    let mut use_percent = String::from("unknown");
8229    let mut mnt_c_present = None;
8230
8231    for line in text.lines() {
8232        let trimmed = line.trim();
8233        if trimmed.starts_with("__MNTC__:") {
8234            mnt_c_present = Some(trimmed.ends_with("ok"));
8235            continue;
8236        }
8237        let cols: Vec<&str> = trimmed.split_whitespace().collect();
8238        if cols.len() >= 6 {
8239            total_kb = cols[1].parse::<u64>().unwrap_or(0);
8240            used_kb = cols[2].parse::<u64>().unwrap_or(0);
8241            avail_kb = cols[3].parse::<u64>().unwrap_or(0);
8242            use_percent = cols[4].to_string();
8243        }
8244    }
8245
8246    Some(WslRootUsage {
8247        total_kb,
8248        used_kb,
8249        avail_kb,
8250        use_percent,
8251        mnt_c_present,
8252    })
8253}
8254
8255#[cfg(target_os = "windows")]
8256fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
8257    let mut vhds = Vec::new();
8258    let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
8259        return vhds;
8260    };
8261    let packages_dir = local_app_data.join("Packages");
8262    let Ok(entries) = fs::read_dir(packages_dir) else {
8263        return vhds;
8264    };
8265
8266    for entry in entries.flatten() {
8267        let path = entry.path().join("LocalState").join("ext4.vhdx");
8268        if let Ok(metadata) = fs::metadata(&path) {
8269            vhds.push((path, metadata.len()));
8270        }
8271    }
8272    vhds.sort_by(|a, b| b.1.cmp(&a.1));
8273    vhds
8274}
8275
8276fn inspect_docker(max_entries: usize) -> Result<String, String> {
8277    let mut out = String::from("Host inspection: docker\n\n");
8278    let n = max_entries.clamp(5, 25);
8279
8280    let version_output = Command::new("docker")
8281        .args(["version", "--format", "{{.Server.Version}}"])
8282        .output();
8283
8284    match version_output {
8285        Err(_) => {
8286            out.push_str("Docker: not found on PATH.\n");
8287            out.push_str(
8288                "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
8289            );
8290            return Ok(out.trim_end().to_string());
8291        }
8292        Ok(o) if !o.status.success() => {
8293            let stderr = String::from_utf8_lossy(&o.stderr);
8294            if stderr.contains("cannot connect")
8295                || stderr.contains("Is the docker daemon running")
8296                || stderr.contains("pipe")
8297                || stderr.contains("socket")
8298            {
8299                out.push_str("Docker: installed but daemon is NOT running.\n");
8300                out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
8301            } else {
8302                out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
8303            }
8304            return Ok(out.trim_end().to_string());
8305        }
8306        Ok(o) => {
8307            let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
8308            out.push_str(&format!("Docker Engine: {version}\n"));
8309        }
8310    }
8311
8312    if let Ok(o) = Command::new("docker")
8313        .args([
8314            "info",
8315            "--format",
8316            "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
8317        ])
8318        .output()
8319    {
8320        let info = String::from_utf8_lossy(&o.stdout);
8321        for line in info.lines() {
8322            let t = line.trim();
8323            if !t.is_empty() {
8324                out.push_str(&format!("  {t}\n"));
8325            }
8326        }
8327        out.push('\n');
8328    }
8329
8330    if let Ok(o) = Command::new("docker")
8331        .args([
8332            "ps",
8333            "--format",
8334            "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
8335        ])
8336        .output()
8337    {
8338        let raw = String::from_utf8_lossy(&o.stdout);
8339        let lines: Vec<&str> = raw.lines().collect();
8340        if lines.len() <= 1 {
8341            out.push_str("Running containers: none\n\n");
8342        } else {
8343            out.push_str(&format!(
8344                "=== Running containers ({}) ===\n",
8345                lines.len().saturating_sub(1)
8346            ));
8347            for line in lines.iter().take(n + 1) {
8348                out.push_str(&format!("  {line}\n"));
8349            }
8350            if lines.len() > n + 1 {
8351                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
8352            }
8353            out.push('\n');
8354        }
8355    }
8356
8357    if let Ok(o) = Command::new("docker")
8358        .args([
8359            "images",
8360            "--format",
8361            "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
8362        ])
8363        .output()
8364    {
8365        let raw = String::from_utf8_lossy(&o.stdout);
8366        let lines: Vec<&str> = raw.lines().collect();
8367        if lines.len() > 1 {
8368            out.push_str(&format!(
8369                "=== Local images ({}) ===\n",
8370                lines.len().saturating_sub(1)
8371            ));
8372            for line in lines.iter().take(n + 1) {
8373                out.push_str(&format!("  {line}\n"));
8374            }
8375            if lines.len() > n + 1 {
8376                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
8377            }
8378            out.push('\n');
8379        }
8380    }
8381
8382    if let Ok(o) = Command::new("docker")
8383        .args([
8384            "compose",
8385            "ls",
8386            "--format",
8387            "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
8388        ])
8389        .output()
8390    {
8391        let raw = String::from_utf8_lossy(&o.stdout);
8392        let lines: Vec<&str> = raw.lines().collect();
8393        if lines.len() > 1 {
8394            out.push_str(&format!(
8395                "=== Compose projects ({}) ===\n",
8396                lines.len().saturating_sub(1)
8397            ));
8398            for line in lines.iter().take(n + 1) {
8399                out.push_str(&format!("  {line}\n"));
8400            }
8401            out.push('\n');
8402        }
8403    }
8404
8405    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8406        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8407        if !ctx.is_empty() {
8408            out.push_str(&format!("Active context: {ctx}\n"));
8409        }
8410    }
8411
8412    Ok(out.trim_end().to_string())
8413}
8414
8415// ── wsl ───────────────────────────────────────────────────────────────────────
8416
8417fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
8418    let mut out = String::from("Host inspection: docker_filesystems\n\n");
8419    let n = max_entries.clamp(3, 12);
8420
8421    match docker_engine_version() {
8422        Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
8423        Err(message) => {
8424            out.push_str(&message);
8425            return Ok(out.trim_end().to_string());
8426        }
8427    }
8428
8429    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8430        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8431        if !ctx.is_empty() {
8432            out.push_str(&format!("Active context: {ctx}\n"));
8433        }
8434    }
8435    out.push('\n');
8436
8437    let mut containers = Vec::new();
8438    if let Ok(o) = Command::new("docker")
8439        .args([
8440            "ps",
8441            "-a",
8442            "--format",
8443            "{{.Names}}\t{{.Image}}\t{{.Status}}",
8444        ])
8445        .output()
8446    {
8447        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8448            let cols: Vec<&str> = line.split('\t').collect();
8449            if cols.len() < 3 {
8450                continue;
8451            }
8452            let name = cols[0].trim().to_string();
8453            if name.is_empty() {
8454                continue;
8455            }
8456            let inspect_output = Command::new("docker")
8457                .args(["inspect", &name, "--format", "{{json .Mounts}}"])
8458                .output();
8459            let mounts = match inspect_output {
8460                Ok(result) if result.status.success() => {
8461                    parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
8462                }
8463                _ => Vec::new(),
8464            };
8465            containers.push(DockerContainerAudit {
8466                name,
8467                image: cols[1].trim().to_string(),
8468                status: cols[2].trim().to_string(),
8469                mounts,
8470            });
8471        }
8472    }
8473
8474    let mut volumes = Vec::new();
8475    if let Ok(o) = Command::new("docker")
8476        .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
8477        .output()
8478    {
8479        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8480            let cols: Vec<&str> = line.split('\t').collect();
8481            let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
8482                continue;
8483            };
8484            let mut audit = inspect_docker_volume(name);
8485            if audit.driver == "unknown" {
8486                audit.driver = cols
8487                    .get(1)
8488                    .map(|v| v.trim())
8489                    .filter(|v| !v.is_empty())
8490                    .unwrap_or("unknown")
8491                    .to_string();
8492            }
8493            volumes.push(audit);
8494        }
8495    }
8496
8497    let mut findings = Vec::new();
8498    for container in &containers {
8499        for mount in &container.mounts {
8500            if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8501                let source = mount.source.as_deref().unwrap_or("<unknown>");
8502                findings.push(AuditFinding {
8503                    finding: format!(
8504                        "Container '{}' has a bind mount whose host source is missing: {} -> {}",
8505                        container.name, source, mount.destination
8506                    ),
8507                    impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
8508                    fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
8509                });
8510            }
8511        }
8512    }
8513
8514    #[cfg(target_os = "windows")]
8515    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8516        if size_bytes >= 20 * 1024 * 1024 * 1024 {
8517            findings.push(AuditFinding {
8518                finding: format!(
8519                    "Docker Desktop disk image is large: {} at {}",
8520                    human_bytes(size_bytes),
8521                    path.display()
8522                ),
8523                impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
8524                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(),
8525            });
8526        }
8527    }
8528
8529    out.push_str("=== Findings ===\n");
8530    if findings.is_empty() {
8531        out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
8532        out.push_str("  Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
8533        out.push_str("  Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
8534    } else {
8535        for finding in &findings {
8536            out.push_str(&format!("- Finding: {}\n", finding.finding));
8537            out.push_str(&format!("  Impact: {}\n", finding.impact));
8538            out.push_str(&format!("  Fix: {}\n", finding.fix));
8539        }
8540    }
8541
8542    out.push_str("\n=== Container mount summary ===\n");
8543    if containers.is_empty() {
8544        out.push_str("- No containers found.\n");
8545    } else {
8546        for container in &containers {
8547            out.push_str(&format!(
8548                "- {} ({}) [{}]\n",
8549                container.name, container.image, container.status
8550            ));
8551            if container.mounts.is_empty() {
8552                out.push_str("  - no mounts reported\n");
8553                continue;
8554            }
8555            for mount in &container.mounts {
8556                let mut source = mount
8557                    .name
8558                    .clone()
8559                    .or_else(|| mount.source.clone())
8560                    .unwrap_or_else(|| "<unknown>".to_string());
8561                if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8562                    source.push_str(" [missing]");
8563                }
8564                let mut extras = Vec::new();
8565                if let Some(rw) = mount.read_write {
8566                    extras.push(if rw { "rw" } else { "ro" }.to_string());
8567                }
8568                if let Some(driver) = &mount.driver {
8569                    extras.push(format!("driver={driver}"));
8570                }
8571                let extra_suffix = if extras.is_empty() {
8572                    String::new()
8573                } else {
8574                    format!(" ({})", extras.join(", "))
8575                };
8576                out.push_str(&format!(
8577                    "  - {}: {} -> {}{}\n",
8578                    mount.mount_type, source, mount.destination, extra_suffix
8579                ));
8580            }
8581        }
8582    }
8583
8584    out.push_str("\n=== Named volumes ===\n");
8585    if volumes.is_empty() {
8586        out.push_str("- No named volumes found.\n");
8587    } else {
8588        for volume in &volumes {
8589            let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8590            if let Some(scope) = &volume.scope {
8591                detail.push_str(&format!(", scope: {scope}"));
8592            }
8593            if let Some(mountpoint) = &volume.mountpoint {
8594                detail.push_str(&format!(", mountpoint: {mountpoint}"));
8595            }
8596            out.push_str(&format!("{detail}\n"));
8597        }
8598    }
8599
8600    #[cfg(target_os = "windows")]
8601    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8602        out.push_str("\n=== Docker Desktop disk ===\n");
8603        out.push_str(&format!(
8604            "- {} at {}\n",
8605            human_bytes(size_bytes),
8606            path.display()
8607        ));
8608    }
8609
8610    Ok(out.trim_end().to_string())
8611}
8612
8613fn inspect_wsl() -> Result<String, String> {
8614    let mut out = String::from("Host inspection: wsl\n\n");
8615
8616    #[cfg(target_os = "windows")]
8617    {
8618        if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8619            let raw = String::from_utf8_lossy(&o.stdout);
8620            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8621            for line in cleaned.lines().take(4) {
8622                let t = line.trim();
8623                if !t.is_empty() {
8624                    out.push_str(&format!("  {t}\n"));
8625                }
8626            }
8627            out.push('\n');
8628        }
8629
8630        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8631        match list_output {
8632            Err(e) => {
8633                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8634                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8635            }
8636            Ok(o) if !o.status.success() => {
8637                let stderr = String::from_utf8_lossy(&o.stderr);
8638                let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8639                out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
8640                out.push_str("Run: wsl --install\n");
8641            }
8642            Ok(o) => {
8643                let raw = String::from_utf8_lossy(&o.stdout);
8644                let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8645                let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8646                let distro_lines: Vec<&str> = lines
8647                    .iter()
8648                    .filter(|l| {
8649                        let t = l.trim();
8650                        !t.is_empty()
8651                            && !t.to_uppercase().starts_with("NAME")
8652                            && !t.starts_with("---")
8653                    })
8654                    .copied()
8655                    .collect();
8656
8657                if distro_lines.is_empty() {
8658                    out.push_str("WSL: installed but no distributions found.\n");
8659                    out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8660                } else {
8661                    out.push_str("=== WSL Distributions ===\n");
8662                    for line in &lines {
8663                        out.push_str(&format!("  {}\n", line.trim()));
8664                    }
8665                    out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
8666                }
8667            }
8668        }
8669
8670        if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8671            let raw = String::from_utf8_lossy(&o.stdout);
8672            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8673            let status_lines: Vec<&str> = cleaned
8674                .lines()
8675                .filter(|l| !l.trim().is_empty())
8676                .take(8)
8677                .collect();
8678            if !status_lines.is_empty() {
8679                out.push_str("\n=== WSL status ===\n");
8680                for line in status_lines {
8681                    out.push_str(&format!("  {}\n", line.trim()));
8682                }
8683            }
8684        }
8685    }
8686
8687    #[cfg(not(target_os = "windows"))]
8688    {
8689        out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8690        out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8691    }
8692
8693    Ok(out.trim_end().to_string())
8694}
8695
8696// ── ssh ───────────────────────────────────────────────────────────────────────
8697
8698fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8699    let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8700
8701    #[cfg(target_os = "windows")]
8702    {
8703        let n = max_entries.clamp(3, 12);
8704        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8705        let distros = match list_output {
8706            Err(e) => {
8707                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8708                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8709                return Ok(out.trim_end().to_string());
8710            }
8711            Ok(o) if !o.status.success() => {
8712                let cleaned = clean_wsl_text(&o.stderr);
8713                out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
8714                out.push_str("Run: wsl --install\n");
8715                return Ok(out.trim_end().to_string());
8716            }
8717            Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8718        };
8719
8720        out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
8721
8722        let vhdx_files = collect_wsl_vhdx_files();
8723        let mut findings = Vec::new();
8724        let mut live_usage = Vec::new();
8725
8726        for distro in distros.iter().take(n) {
8727            if distro.state.eq_ignore_ascii_case("Running") {
8728                if let Some(usage) = wsl_root_usage(&distro.name) {
8729                    if let Some(false) = usage.mnt_c_present {
8730                        findings.push(AuditFinding {
8731                            finding: format!(
8732                                "Distro '{}' is running without /mnt/c available",
8733                                distro.name
8734                            ),
8735                            impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8736                            fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8737                        });
8738                    }
8739
8740                    let percent_num = usage
8741                        .use_percent
8742                        .trim_end_matches('%')
8743                        .parse::<u32>()
8744                        .unwrap_or(0);
8745                    if percent_num >= 85 {
8746                        findings.push(AuditFinding {
8747                            finding: format!(
8748                                "Distro '{}' root filesystem is {} full",
8749                                distro.name, usage.use_percent
8750                            ),
8751                            impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8752                            fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8753                        });
8754                    }
8755                    live_usage.push((distro.name.clone(), usage));
8756                }
8757            }
8758        }
8759
8760        for (path, size_bytes) in vhdx_files.iter().take(n) {
8761            if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8762                findings.push(AuditFinding {
8763                    finding: format!(
8764                        "Host-side WSL disk image is large: {} at {}",
8765                        human_bytes(*size_bytes),
8766                        path.display()
8767                    ),
8768                    impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8769                    fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8770                });
8771            }
8772        }
8773
8774        out.push_str("=== Findings ===\n");
8775        if findings.is_empty() {
8776            out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8777            out.push_str("  Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8778            out.push_str("  Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8779        } else {
8780            for finding in &findings {
8781                out.push_str(&format!("- Finding: {}\n", finding.finding));
8782                out.push_str(&format!("  Impact: {}\n", finding.impact));
8783                out.push_str(&format!("  Fix: {}\n", finding.fix));
8784            }
8785        }
8786
8787        out.push_str("\n=== Distro bridge and root usage ===\n");
8788        if distros.is_empty() {
8789            out.push_str("- No WSL distributions found.\n");
8790        } else {
8791            for distro in distros.iter().take(n) {
8792                out.push_str(&format!(
8793                    "- {} [state: {}, version: {}]\n",
8794                    distro.name, distro.state, distro.version
8795                ));
8796                if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8797                    out.push_str(&format!(
8798                        "  - rootfs: {} used / {} total ({}), free: {}\n",
8799                        human_bytes(usage.used_kb * 1024),
8800                        human_bytes(usage.total_kb * 1024),
8801                        usage.use_percent,
8802                        human_bytes(usage.avail_kb * 1024)
8803                    ));
8804                    match usage.mnt_c_present {
8805                        Some(true) => out.push_str("  - /mnt/c bridge: present\n"),
8806                        Some(false) => out.push_str("  - /mnt/c bridge: missing\n"),
8807                        None => out.push_str("  - /mnt/c bridge: unknown\n"),
8808                    }
8809                } else if distro.state.eq_ignore_ascii_case("Running") {
8810                    out.push_str("  - live rootfs check: unavailable\n");
8811                } else {
8812                    out.push_str(
8813                        "  - live rootfs check: skipped to avoid starting a stopped distro\n",
8814                    );
8815                }
8816            }
8817        }
8818
8819        out.push_str("\n=== Host-side VHDX files ===\n");
8820        if vhdx_files.is_empty() {
8821            out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8822        } else {
8823            for (path, size_bytes) in vhdx_files.iter().take(n) {
8824                out.push_str(&format!(
8825                    "- {} at {}\n",
8826                    human_bytes(*size_bytes),
8827                    path.display()
8828                ));
8829            }
8830        }
8831    }
8832
8833    #[cfg(not(target_os = "windows"))]
8834    {
8835        let _ = max_entries;
8836        out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8837        out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8838    }
8839
8840    Ok(out.trim_end().to_string())
8841}
8842
8843fn dirs_home() -> Option<PathBuf> {
8844    std::env::var("HOME")
8845        .ok()
8846        .map(PathBuf::from)
8847        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8848}
8849
8850fn inspect_ssh() -> Result<String, String> {
8851    let mut out = String::from("Host inspection: ssh\n\n");
8852
8853    if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8854        let ver = if o.stdout.is_empty() {
8855            String::from_utf8_lossy(&o.stderr).trim().to_string()
8856        } else {
8857            String::from_utf8_lossy(&o.stdout).trim().to_string()
8858        };
8859        if !ver.is_empty() {
8860            out.push_str(&format!("SSH client: {ver}\n"));
8861        }
8862    } else {
8863        out.push_str("SSH client: not found on PATH.\n");
8864    }
8865
8866    #[cfg(target_os = "windows")]
8867    {
8868        let script = r#"
8869$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8870if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8871else { "SSHD:not_installed" }
8872"#;
8873        if let Ok(o) = Command::new("powershell")
8874            .args(["-NoProfile", "-Command", script])
8875            .output()
8876        {
8877            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8878            if text.contains("not_installed") {
8879                out.push_str("SSH server (sshd): not installed\n");
8880            } else {
8881                out.push_str(&format!(
8882                    "SSH server (sshd): {}\n",
8883                    text.trim_start_matches("SSHD:")
8884                ));
8885            }
8886        }
8887    }
8888
8889    #[cfg(not(target_os = "windows"))]
8890    {
8891        if let Ok(o) = Command::new("systemctl")
8892            .args(["is-active", "sshd"])
8893            .output()
8894        {
8895            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8896            out.push_str(&format!("SSH server (sshd): {status}\n"));
8897        } else if let Ok(o) = Command::new("systemctl")
8898            .args(["is-active", "ssh"])
8899            .output()
8900        {
8901            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8902            out.push_str(&format!("SSH server (ssh): {status}\n"));
8903        }
8904    }
8905
8906    out.push('\n');
8907
8908    if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8909        if ssh_dir.exists() {
8910            out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8911
8912            let kh = ssh_dir.join("known_hosts");
8913            if kh.exists() {
8914                let count = fs::read_to_string(&kh)
8915                    .map(|c| {
8916                        c.lines()
8917                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8918                            .count()
8919                    })
8920                    .unwrap_or(0);
8921                out.push_str(&format!("  known_hosts: {count} entries\n"));
8922            } else {
8923                out.push_str("  known_hosts: not present\n");
8924            }
8925
8926            let ak = ssh_dir.join("authorized_keys");
8927            if ak.exists() {
8928                let count = fs::read_to_string(&ak)
8929                    .map(|c| {
8930                        c.lines()
8931                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8932                            .count()
8933                    })
8934                    .unwrap_or(0);
8935                out.push_str(&format!("  authorized_keys: {count} public keys\n"));
8936            } else {
8937                out.push_str("  authorized_keys: not present\n");
8938            }
8939
8940            let key_names = [
8941                "id_rsa",
8942                "id_ed25519",
8943                "id_ecdsa",
8944                "id_dsa",
8945                "id_ecdsa_sk",
8946                "id_ed25519_sk",
8947            ];
8948            let found_keys: Vec<&str> = key_names
8949                .iter()
8950                .filter(|k| ssh_dir.join(k).exists())
8951                .copied()
8952                .collect();
8953            if !found_keys.is_empty() {
8954                out.push_str(&format!("  Private keys: {}\n", found_keys.join(", ")));
8955            } else {
8956                out.push_str("  Private keys: none found\n");
8957            }
8958
8959            let config_path = ssh_dir.join("config");
8960            if config_path.exists() {
8961                out.push_str("\n=== SSH config hosts ===\n");
8962                match fs::read_to_string(&config_path) {
8963                    Ok(content) => {
8964                        let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8965                        let mut current: Option<(String, Vec<String>)> = None;
8966                        for line in content.lines() {
8967                            let t = line.trim();
8968                            if t.is_empty() || t.starts_with('#') {
8969                                continue;
8970                            }
8971                            if let Some(host) = t.strip_prefix("Host ") {
8972                                if let Some(prev) = current.take() {
8973                                    hosts.push(prev);
8974                                }
8975                                current = Some((host.trim().to_string(), Vec::new()));
8976                            } else if let Some((_, ref mut details)) = current {
8977                                let tu = t.to_uppercase();
8978                                if tu.starts_with("HOSTNAME ")
8979                                    || tu.starts_with("USER ")
8980                                    || tu.starts_with("PORT ")
8981                                    || tu.starts_with("IDENTITYFILE ")
8982                                {
8983                                    details.push(t.to_string());
8984                                }
8985                            }
8986                        }
8987                        if let Some(prev) = current {
8988                            hosts.push(prev);
8989                        }
8990
8991                        if hosts.is_empty() {
8992                            out.push_str("  No Host entries found.\n");
8993                        } else {
8994                            for (h, details) in &hosts {
8995                                if details.is_empty() {
8996                                    out.push_str(&format!("  Host {h}\n"));
8997                                } else {
8998                                    out.push_str(&format!(
8999                                        "  Host {h}  [{}]\n",
9000                                        details.join(", ")
9001                                    ));
9002                                }
9003                            }
9004                            out.push_str(&format!("\n  Total configured hosts: {}\n", hosts.len()));
9005                        }
9006                    }
9007                    Err(e) => out.push_str(&format!("  Could not read config: {e}\n")),
9008                }
9009            } else {
9010                out.push_str("  SSH config: not present\n");
9011            }
9012        } else {
9013            out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
9014        }
9015    }
9016
9017    Ok(out.trim_end().to_string())
9018}
9019
9020// ── installed_software ────────────────────────────────────────────────────────
9021
9022fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
9023    let mut out = String::from("Host inspection: installed_software\n\n");
9024    let n = max_entries.clamp(10, 50);
9025
9026    #[cfg(target_os = "windows")]
9027    {
9028        let winget_out = Command::new("winget")
9029            .args(["list", "--accept-source-agreements"])
9030            .output();
9031
9032        if let Ok(o) = winget_out {
9033            if o.status.success() {
9034                let raw = String::from_utf8_lossy(&o.stdout);
9035                let mut header_done = false;
9036                let mut packages: Vec<&str> = Vec::new();
9037                for line in raw.lines() {
9038                    let t = line.trim();
9039                    if t.starts_with("---") {
9040                        header_done = true;
9041                        continue;
9042                    }
9043                    if header_done && !t.is_empty() {
9044                        packages.push(line);
9045                    }
9046                }
9047                let total = packages.len();
9048                out.push_str(&format!(
9049                    "=== Installed software via winget ({total} packages) ===\n\n"
9050                ));
9051                for line in packages.iter().take(n) {
9052                    out.push_str(&format!("  {line}\n"));
9053                }
9054                if total > n {
9055                    out.push_str(&format!("\n  ... and {} more packages\n", total - n));
9056                }
9057                out.push_str("\nFor full list: winget list\n");
9058                return Ok(out.trim_end().to_string());
9059            }
9060        }
9061
9062        // Fallback: registry scan
9063        let script = format!(
9064            r#"
9065$apps = @()
9066$reg_paths = @(
9067    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
9068    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
9069    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
9070)
9071foreach ($p in $reg_paths) {{
9072    try {{
9073        $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
9074            Where-Object {{ $_.DisplayName }} |
9075            Select-Object DisplayName, DisplayVersion, Publisher
9076    }} catch {{}}
9077}}
9078$sorted = $apps | Sort-Object DisplayName -Unique
9079"TOTAL:" + $sorted.Count
9080$sorted | Select-Object -First {n} | ForEach-Object {{
9081    $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
9082}}
9083"#
9084        );
9085        if let Ok(o) = Command::new("powershell")
9086            .args(["-NoProfile", "-Command", &script])
9087            .output()
9088        {
9089            let raw = String::from_utf8_lossy(&o.stdout);
9090            out.push_str("=== Installed software (registry scan) ===\n");
9091            out.push_str(&format!("  {:<50} {:<18} Publisher\n", "Name", "Version"));
9092            out.push_str(&format!("  {}\n", "-".repeat(90)));
9093            for line in raw.lines() {
9094                if let Some(rest) = line.strip_prefix("TOTAL:") {
9095                    let total: usize = rest.trim().parse().unwrap_or(0);
9096                    out.push_str(&format!("  (Total: {total}, showing first {n})\n\n"));
9097                } else if !line.trim().is_empty() {
9098                    let parts: Vec<&str> = line.splitn(3, '|').collect();
9099                    let name = parts.first().map(|s| s.trim()).unwrap_or("");
9100                    let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
9101                    let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
9102                    out.push_str(&format!("  {:<50} {:<18} {pub_}\n", name, ver));
9103                }
9104            }
9105        } else {
9106            out.push_str(
9107                "Could not query installed software (winget and registry scan both failed).\n",
9108            );
9109        }
9110    }
9111
9112    #[cfg(target_os = "linux")]
9113    {
9114        let mut found = false;
9115        if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
9116            if o.status.success() {
9117                let raw = String::from_utf8_lossy(&o.stdout);
9118                let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
9119                let total = installed.len();
9120                out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
9121                for line in installed.iter().take(n) {
9122                    out.push_str(&format!("  {}\n", line.trim()));
9123                }
9124                if total > n {
9125                    out.push_str(&format!("  ... and {} more\n", total - n));
9126                }
9127                out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
9128                found = true;
9129            }
9130        }
9131        if !found {
9132            if let Ok(o) = Command::new("rpm")
9133                .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
9134                .output()
9135            {
9136                if o.status.success() {
9137                    let raw = String::from_utf8_lossy(&o.stdout);
9138                    let lines: Vec<&str> = raw.lines().collect();
9139                    let total = lines.len();
9140                    out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
9141                    for line in lines.iter().take(n) {
9142                        out.push_str(&format!("  {line}\n"));
9143                    }
9144                    if total > n {
9145                        out.push_str(&format!("  ... and {} more\n", total - n));
9146                    }
9147                    found = true;
9148                }
9149            }
9150        }
9151        if !found {
9152            if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
9153                if o.status.success() {
9154                    let raw = String::from_utf8_lossy(&o.stdout);
9155                    let lines: Vec<&str> = raw.lines().collect();
9156                    let total = lines.len();
9157                    out.push_str(&format!(
9158                        "=== Installed packages via pacman ({total}) ===\n"
9159                    ));
9160                    for line in lines.iter().take(n) {
9161                        out.push_str(&format!("  {line}\n"));
9162                    }
9163                    if total > n {
9164                        out.push_str(&format!("  ... and {} more\n", total - n));
9165                    }
9166                    found = true;
9167                }
9168            }
9169        }
9170        if !found {
9171            out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
9172        }
9173    }
9174
9175    #[cfg(target_os = "macos")]
9176    {
9177        if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
9178            if o.status.success() {
9179                let raw = String::from_utf8_lossy(&o.stdout);
9180                let lines: Vec<&str> = raw.lines().collect();
9181                let total = lines.len();
9182                out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
9183                for line in lines.iter().take(n) {
9184                    out.push_str(&format!("  {line}\n"));
9185                }
9186                if total > n {
9187                    out.push_str(&format!("  ... and {} more\n", total - n));
9188                }
9189                out.push_str("\nFor full list: brew list --versions\n");
9190            }
9191        } else {
9192            out.push_str("Homebrew not found.\n");
9193        }
9194        if let Ok(o) = Command::new("mas").args(["list"]).output() {
9195            if o.status.success() {
9196                let raw = String::from_utf8_lossy(&o.stdout);
9197                let lines: Vec<&str> = raw.lines().collect();
9198                out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
9199                for line in lines.iter().take(n) {
9200                    out.push_str(&format!("  {line}\n"));
9201                }
9202            }
9203        }
9204    }
9205
9206    Ok(out.trim_end().to_string())
9207}
9208
9209// ── git_config ────────────────────────────────────────────────────────────────
9210
9211fn inspect_git_config() -> Result<String, String> {
9212    let mut out = String::from("Host inspection: git_config\n\n");
9213
9214    if let Ok(o) = Command::new("git").args(["--version"]).output() {
9215        let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
9216        out.push_str(&format!("Git: {ver}\n\n"));
9217    } else {
9218        out.push_str("Git: not found on PATH.\n");
9219        return Ok(out.trim_end().to_string());
9220    }
9221
9222    if let Ok(o) = Command::new("git")
9223        .args(["config", "--global", "--list"])
9224        .output()
9225    {
9226        if o.status.success() {
9227            let raw = String::from_utf8_lossy(&o.stdout);
9228            let mut pairs: Vec<(String, String)> = raw
9229                .lines()
9230                .filter_map(|l| {
9231                    let mut parts = l.splitn(2, '=');
9232                    let k = parts.next()?.trim().to_string();
9233                    let v = parts.next().unwrap_or("").trim().to_string();
9234                    Some((k, v))
9235                })
9236                .collect();
9237            pairs.sort_by(|a, b| a.0.cmp(&b.0));
9238
9239            out.push_str("=== Global git config ===\n");
9240
9241            let sections: &[(&str, &[&str])] = &[
9242                ("Identity", &["user.name", "user.email", "user.signingkey"]),
9243                (
9244                    "Core",
9245                    &[
9246                        "core.editor",
9247                        "core.autocrlf",
9248                        "core.eol",
9249                        "core.ignorecase",
9250                        "core.filemode",
9251                    ],
9252                ),
9253                (
9254                    "Commit/Signing",
9255                    &[
9256                        "commit.gpgsign",
9257                        "tag.gpgsign",
9258                        "gpg.format",
9259                        "gpg.ssh.allowedsignersfile",
9260                    ],
9261                ),
9262                (
9263                    "Push/Pull",
9264                    &[
9265                        "push.default",
9266                        "push.autosetupremote",
9267                        "pull.rebase",
9268                        "pull.ff",
9269                    ],
9270                ),
9271                ("Credential", &["credential.helper"]),
9272                ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
9273            ];
9274
9275            let mut shown_keys: HashSet<String> = HashSet::new();
9276            for (section, keys) in sections {
9277                let mut section_lines: Vec<String> = Vec::new();
9278                for key in *keys {
9279                    if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
9280                        section_lines.push(format!("  {k} = {v}"));
9281                        shown_keys.insert(k.clone());
9282                    }
9283                }
9284                if !section_lines.is_empty() {
9285                    out.push_str(&format!("\n[{section}]\n"));
9286                    for line in section_lines {
9287                        out.push_str(&format!("{line}\n"));
9288                    }
9289                }
9290            }
9291
9292            let other: Vec<&(String, String)> = pairs
9293                .iter()
9294                .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
9295                .collect();
9296            if !other.is_empty() {
9297                out.push_str("\n[Other]\n");
9298                for (k, v) in other.iter().take(20) {
9299                    out.push_str(&format!("  {k} = {v}\n"));
9300                }
9301                if other.len() > 20 {
9302                    out.push_str(&format!("  ... and {} more\n", other.len() - 20));
9303                }
9304            }
9305
9306            out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
9307        } else {
9308            out.push_str("No global git config found.\n");
9309            out.push_str("Set up with:\n");
9310            out.push_str("  git config --global user.name \"Your Name\"\n");
9311            out.push_str("  git config --global user.email \"you@example.com\"\n");
9312        }
9313    }
9314
9315    if let Ok(o) = Command::new("git")
9316        .args(["config", "--local", "--list"])
9317        .output()
9318    {
9319        if o.status.success() {
9320            let raw = String::from_utf8_lossy(&o.stdout);
9321            let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9322            if !lines.is_empty() {
9323                out.push_str(&format!(
9324                    "\n=== Local repo config ({} keys) ===\n",
9325                    lines.len()
9326                ));
9327                for line in lines.iter().take(15) {
9328                    out.push_str(&format!("  {line}\n"));
9329                }
9330                if lines.len() > 15 {
9331                    out.push_str(&format!("  ... and {} more\n", lines.len() - 15));
9332                }
9333            }
9334        }
9335    }
9336
9337    if let Ok(o) = Command::new("git")
9338        .args(["config", "--global", "--get-regexp", r"alias\."])
9339        .output()
9340    {
9341        if o.status.success() {
9342            let raw = String::from_utf8_lossy(&o.stdout);
9343            let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9344            if !aliases.is_empty() {
9345                out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
9346                for a in aliases.iter().take(20) {
9347                    out.push_str(&format!("  {a}\n"));
9348                }
9349                if aliases.len() > 20 {
9350                    out.push_str(&format!("  ... and {} more\n", aliases.len() - 20));
9351                }
9352            }
9353        }
9354    }
9355
9356    Ok(out.trim_end().to_string())
9357}
9358
9359// ── databases ─────────────────────────────────────────────────────────────────
9360
9361fn inspect_databases() -> Result<String, String> {
9362    let mut out = String::from("Host inspection: databases\n\n");
9363    out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
9364
9365    struct DbEngine {
9366        name: &'static str,
9367        service_names: &'static [&'static str],
9368        default_port: u16,
9369        cli_name: &'static str,
9370        cli_version_args: &'static [&'static str],
9371    }
9372
9373    let engines: &[DbEngine] = &[
9374        DbEngine {
9375            name: "PostgreSQL",
9376            service_names: &[
9377                "postgresql",
9378                "postgresql-x64-14",
9379                "postgresql-x64-15",
9380                "postgresql-x64-16",
9381                "postgresql-x64-17",
9382            ],
9383
9384            default_port: 5432,
9385            cli_name: "psql",
9386            cli_version_args: &["--version"],
9387        },
9388        DbEngine {
9389            name: "MySQL",
9390            service_names: &["mysql", "mysql80", "mysql57"],
9391
9392            default_port: 3306,
9393            cli_name: "mysql",
9394            cli_version_args: &["--version"],
9395        },
9396        DbEngine {
9397            name: "MariaDB",
9398            service_names: &["mariadb", "mariadb.exe"],
9399
9400            default_port: 3306,
9401            cli_name: "mariadb",
9402            cli_version_args: &["--version"],
9403        },
9404        DbEngine {
9405            name: "MongoDB",
9406            service_names: &["mongodb", "mongod"],
9407
9408            default_port: 27017,
9409            cli_name: "mongod",
9410            cli_version_args: &["--version"],
9411        },
9412        DbEngine {
9413            name: "Redis",
9414            service_names: &["redis", "redis-server"],
9415
9416            default_port: 6379,
9417            cli_name: "redis-server",
9418            cli_version_args: &["--version"],
9419        },
9420        DbEngine {
9421            name: "SQL Server",
9422            service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
9423
9424            default_port: 1433,
9425            cli_name: "sqlcmd",
9426            cli_version_args: &["-?"],
9427        },
9428        DbEngine {
9429            name: "SQLite",
9430            service_names: &[], // no service — file-based
9431
9432            default_port: 0, // no port — file-based
9433            cli_name: "sqlite3",
9434            cli_version_args: &["--version"],
9435        },
9436        DbEngine {
9437            name: "CouchDB",
9438            service_names: &["couchdb", "apache-couchdb"],
9439
9440            default_port: 5984,
9441            cli_name: "couchdb",
9442            cli_version_args: &["--version"],
9443        },
9444        DbEngine {
9445            name: "Cassandra",
9446            service_names: &["cassandra"],
9447
9448            default_port: 9042,
9449            cli_name: "cqlsh",
9450            cli_version_args: &["--version"],
9451        },
9452        DbEngine {
9453            name: "Elasticsearch",
9454            service_names: &["elasticsearch-service-x64", "elasticsearch"],
9455
9456            default_port: 9200,
9457            cli_name: "elasticsearch",
9458            cli_version_args: &["--version"],
9459        },
9460    ];
9461
9462    // Helper: check if port is listening
9463    fn port_listening(port: u16) -> bool {
9464        if port == 0 {
9465            return false;
9466        }
9467        // Use netstat-style check via connecting
9468        std::net::TcpStream::connect_timeout(
9469            &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9470            std::time::Duration::from_millis(150),
9471        )
9472        .is_ok()
9473    }
9474
9475    let mut found_any = false;
9476
9477    for engine in engines {
9478        let mut status_parts: Vec<String> = Vec::new();
9479        let mut detected = false;
9480
9481        // 1. CLI version check (fastest — works cross-platform)
9482        let version = Command::new(engine.cli_name)
9483            .args(engine.cli_version_args)
9484            .output()
9485            .ok()
9486            .and_then(|o| {
9487                let combined = if o.stdout.is_empty() {
9488                    String::from_utf8_lossy(&o.stderr).trim().to_string()
9489                } else {
9490                    String::from_utf8_lossy(&o.stdout).trim().to_string()
9491                };
9492                // Take just the first line
9493                combined.lines().next().map(|l| l.trim().to_string())
9494            });
9495
9496        if let Some(ref ver) = version {
9497            if !ver.is_empty() {
9498                status_parts.push(format!("version: {ver}"));
9499                detected = true;
9500            }
9501        }
9502
9503        // 2. Port check
9504        if engine.default_port > 0 && port_listening(engine.default_port) {
9505            status_parts.push(format!("listening on :{}", engine.default_port));
9506            detected = true;
9507        } else if engine.default_port > 0 && detected {
9508            status_parts.push(format!("not listening on :{}", engine.default_port));
9509        }
9510
9511        // 3. Windows service check
9512        #[cfg(target_os = "windows")]
9513        {
9514            if !engine.service_names.is_empty() {
9515                let service_list = engine.service_names.join("','");
9516                let script = format!(
9517                    r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
9518                    service_list
9519                );
9520                if let Ok(o) = Command::new("powershell")
9521                    .args(["-NoProfile", "-Command", &script])
9522                    .output()
9523                {
9524                    let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
9525                    if !text.is_empty() {
9526                        let parts: Vec<&str> = text.splitn(2, ':').collect();
9527                        let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
9528                        let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
9529                        status_parts.push(format!("service '{svc_name}': {svc_state}"));
9530                        detected = true;
9531                    }
9532                }
9533            }
9534        }
9535
9536        // 4. Linux/macOS systemctl / launchctl check
9537        #[cfg(not(target_os = "windows"))]
9538        {
9539            for svc in engine.service_names {
9540                if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
9541                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
9542                    if !state.is_empty() && state != "inactive" {
9543                        status_parts.push(format!("systemd '{svc}': {state}"));
9544                        detected = true;
9545                        break;
9546                    }
9547                }
9548            }
9549        }
9550
9551        if detected {
9552            found_any = true;
9553            let label = if engine.default_port > 0 {
9554                format!("{} (default port: {})", engine.name, engine.default_port)
9555            } else {
9556                format!("{} (file-based, no port)", engine.name)
9557            };
9558            out.push_str(&format!("[FOUND] {label}\n"));
9559            for part in &status_parts {
9560                out.push_str(&format!("  {part}\n"));
9561            }
9562            out.push('\n');
9563        }
9564    }
9565
9566    if !found_any {
9567        out.push_str("No local database engines detected.\n");
9568        out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
9569        out.push_str(
9570            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9571        );
9572    } else {
9573        out.push_str("---\n");
9574        out.push_str(
9575            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9576        );
9577        out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
9578    }
9579
9580    Ok(out.trim_end().to_string())
9581}
9582
9583// ── user_accounts ─────────────────────────────────────────────────────────────
9584
9585fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9586    let mut out = String::from("Host inspection: user_accounts\n\n");
9587
9588    #[cfg(target_os = "windows")]
9589    {
9590        let users_out = Command::new("powershell")
9591            .args([
9592                "-NoProfile", "-NonInteractive", "-Command",
9593                "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \"  $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9594            ])
9595            .output()
9596            .ok()
9597            .and_then(|o| String::from_utf8(o.stdout).ok())
9598            .unwrap_or_default();
9599
9600        out.push_str("=== Local User Accounts ===\n");
9601        if users_out.trim().is_empty() {
9602            out.push_str("  (requires elevation or Get-LocalUser unavailable)\n");
9603        } else {
9604            for line in users_out.lines().take(max_entries) {
9605                if !line.trim().is_empty() {
9606                    out.push_str(line);
9607                    out.push('\n');
9608                }
9609            }
9610        }
9611
9612        let admins_out = Command::new("powershell")
9613            .args([
9614                "-NoProfile", "-NonInteractive", "-Command",
9615                "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \"  $($_.ObjectClass): $($_.Name)\" }",
9616            ])
9617            .output()
9618            .ok()
9619            .and_then(|o| String::from_utf8(o.stdout).ok())
9620            .unwrap_or_default();
9621
9622        out.push_str("\n=== Administrators Group Members ===\n");
9623        if admins_out.trim().is_empty() {
9624            out.push_str("  (unable to retrieve)\n");
9625        } else {
9626            out.push_str(admins_out.trim());
9627            out.push('\n');
9628        }
9629
9630        let sessions_out = Command::new("powershell")
9631            .args([
9632                "-NoProfile",
9633                "-NonInteractive",
9634                "-Command",
9635                "query user 2>$null",
9636            ])
9637            .output()
9638            .ok()
9639            .and_then(|o| String::from_utf8(o.stdout).ok())
9640            .unwrap_or_default();
9641
9642        out.push_str("\n=== Active Logon Sessions ===\n");
9643        if sessions_out.trim().is_empty() {
9644            out.push_str("  (none or requires elevation)\n");
9645        } else {
9646            for line in sessions_out.lines().take(max_entries) {
9647                if !line.trim().is_empty() {
9648                    out.push_str(&format!("  {}\n", line));
9649                }
9650            }
9651        }
9652
9653        let is_admin = Command::new("powershell")
9654            .args([
9655                "-NoProfile", "-NonInteractive", "-Command",
9656                "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9657            ])
9658            .output()
9659            .ok()
9660            .and_then(|o| String::from_utf8(o.stdout).ok())
9661            .map(|s| s.trim().to_lowercase())
9662            .unwrap_or_default();
9663
9664        out.push_str("\n=== Current Session Elevation ===\n");
9665        out.push_str(&format!(
9666            "  Running as Administrator: {}\n",
9667            if is_admin.contains("true") {
9668                "YES"
9669            } else {
9670                "no"
9671            }
9672        ));
9673    }
9674
9675    #[cfg(not(target_os = "windows"))]
9676    {
9677        let who_out = Command::new("who")
9678            .output()
9679            .ok()
9680            .and_then(|o| String::from_utf8(o.stdout).ok())
9681            .unwrap_or_default();
9682        out.push_str("=== Active Sessions ===\n");
9683        if who_out.trim().is_empty() {
9684            out.push_str("  (none)\n");
9685        } else {
9686            for line in who_out.lines().take(max_entries) {
9687                out.push_str(&format!("  {}\n", line));
9688            }
9689        }
9690        let id_out = Command::new("id")
9691            .output()
9692            .ok()
9693            .and_then(|o| String::from_utf8(o.stdout).ok())
9694            .unwrap_or_default();
9695        out.push_str(&format!("\n=== Current User ===\n  {}\n", id_out.trim()));
9696    }
9697
9698    Ok(out.trim_end().to_string())
9699}
9700
9701// ── audit_policy ──────────────────────────────────────────────────────────────
9702
9703fn inspect_audit_policy() -> Result<String, String> {
9704    let mut out = String::from("Host inspection: audit_policy\n\n");
9705
9706    #[cfg(target_os = "windows")]
9707    {
9708        let auditpol_out = Command::new("auditpol")
9709            .args(["/get", "/category:*"])
9710            .output()
9711            .ok()
9712            .and_then(|o| String::from_utf8(o.stdout).ok())
9713            .unwrap_or_default();
9714
9715        if auditpol_out.trim().is_empty()
9716            || auditpol_out.to_lowercase().contains("access is denied")
9717        {
9718            out.push_str("Audit policy requires Administrator elevation to read.\n");
9719            out.push_str(
9720                "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9721            );
9722        } else {
9723            out.push_str("=== Windows Audit Policy ===\n");
9724            let mut any_enabled = false;
9725            for line in auditpol_out.lines() {
9726                let trimmed = line.trim();
9727                if trimmed.is_empty() {
9728                    continue;
9729                }
9730                if trimmed.contains("Success") || trimmed.contains("Failure") {
9731                    out.push_str(&format!("  [ENABLED] {}\n", trimmed));
9732                    any_enabled = true;
9733                } else {
9734                    out.push_str(&format!("  {}\n", trimmed));
9735                }
9736            }
9737            if !any_enabled {
9738                out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9739                out.push_str(
9740                    "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9741                );
9742            }
9743        }
9744
9745        let evtlog = Command::new("powershell")
9746            .args([
9747                "-NoProfile", "-NonInteractive", "-Command",
9748                "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9749            ])
9750            .output()
9751            .ok()
9752            .and_then(|o| String::from_utf8(o.stdout).ok())
9753            .map(|s| s.trim().to_string())
9754            .unwrap_or_default();
9755
9756        out.push_str(&format!(
9757            "\n=== Windows Event Log Service ===\n  Status: {}\n",
9758            if evtlog.is_empty() {
9759                "unknown".to_string()
9760            } else {
9761                evtlog
9762            }
9763        ));
9764    }
9765
9766    #[cfg(not(target_os = "windows"))]
9767    {
9768        let auditd_status = Command::new("systemctl")
9769            .args(["is-active", "auditd"])
9770            .output()
9771            .ok()
9772            .and_then(|o| String::from_utf8(o.stdout).ok())
9773            .map(|s| s.trim().to_string())
9774            .unwrap_or_else(|| "not found".to_string());
9775
9776        out.push_str(&format!(
9777            "=== auditd service ===\n  Status: {}\n",
9778            auditd_status
9779        ));
9780
9781        if auditd_status == "active" {
9782            let rules = Command::new("auditctl")
9783                .args(["-l"])
9784                .output()
9785                .ok()
9786                .and_then(|o| String::from_utf8(o.stdout).ok())
9787                .unwrap_or_default();
9788            out.push_str("\n=== Active Audit Rules ===\n");
9789            if rules.trim().is_empty() || rules.contains("No rules") {
9790                out.push_str("  No rules configured.\n");
9791            } else {
9792                for line in rules.lines() {
9793                    out.push_str(&format!("  {}\n", line));
9794                }
9795            }
9796        }
9797    }
9798
9799    Ok(out.trim_end().to_string())
9800}
9801
9802// ── shares ────────────────────────────────────────────────────────────────────
9803
9804fn inspect_shares(max_entries: usize) -> Result<String, String> {
9805    let mut out = String::from("Host inspection: shares\n\n");
9806
9807    #[cfg(target_os = "windows")]
9808    {
9809        let smb_out = Command::new("powershell")
9810            .args([
9811                "-NoProfile", "-NonInteractive", "-Command",
9812                "Get-SmbShare | ForEach-Object { \"  $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9813            ])
9814            .output()
9815            .ok()
9816            .and_then(|o| String::from_utf8(o.stdout).ok())
9817            .unwrap_or_default();
9818
9819        out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9820        let smb_lines: Vec<&str> = smb_out
9821            .lines()
9822            .filter(|l| !l.trim().is_empty())
9823            .take(max_entries)
9824            .collect();
9825        if smb_lines.is_empty() {
9826            out.push_str("  No SMB shares or unable to retrieve.\n");
9827        } else {
9828            for line in &smb_lines {
9829                let name = line.trim().split('|').next().unwrap_or("").trim();
9830                if name.ends_with('$') {
9831                    out.push_str(&format!("  {}\n", line.trim()));
9832                } else {
9833                    out.push_str(&format!("  [CUSTOM] {}\n", line.trim()));
9834                }
9835            }
9836        }
9837
9838        let smb_security = Command::new("powershell")
9839            .args([
9840                "-NoProfile", "-NonInteractive", "-Command",
9841                "Get-SmbServerConfiguration | ForEach-Object { \"  SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9842            ])
9843            .output()
9844            .ok()
9845            .and_then(|o| String::from_utf8(o.stdout).ok())
9846            .unwrap_or_default();
9847
9848        out.push_str("\n=== SMB Server Security Settings ===\n");
9849        if smb_security.trim().is_empty() {
9850            out.push_str("  (unable to retrieve)\n");
9851        } else {
9852            out.push_str(smb_security.trim());
9853            out.push('\n');
9854            if smb_security.to_lowercase().contains("smb1: true") {
9855                out.push_str("  [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9856            }
9857        }
9858
9859        let drives_out = Command::new("powershell")
9860            .args([
9861                "-NoProfile", "-NonInteractive", "-Command",
9862                "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \"  $($_.Name): -> $($_.DisplayRoot)\" }",
9863            ])
9864            .output()
9865            .ok()
9866            .and_then(|o| String::from_utf8(o.stdout).ok())
9867            .unwrap_or_default();
9868
9869        out.push_str("\n=== Mapped Network Drives ===\n");
9870        if drives_out.trim().is_empty() {
9871            out.push_str("  None.\n");
9872        } else {
9873            for line in drives_out.lines().take(max_entries) {
9874                if !line.trim().is_empty() {
9875                    out.push_str(line);
9876                    out.push('\n');
9877                }
9878            }
9879        }
9880    }
9881
9882    #[cfg(not(target_os = "windows"))]
9883    {
9884        let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9885        out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9886        if smb_conf.is_empty() {
9887            out.push_str("  Not found or Samba not installed.\n");
9888        } else {
9889            for line in smb_conf.lines().take(max_entries) {
9890                out.push_str(&format!("  {}\n", line));
9891            }
9892        }
9893        let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9894        out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9895        if nfs_exports.is_empty() {
9896            out.push_str("  Not configured.\n");
9897        } else {
9898            for line in nfs_exports.lines().take(max_entries) {
9899                out.push_str(&format!("  {}\n", line));
9900            }
9901        }
9902    }
9903
9904    Ok(out.trim_end().to_string())
9905}
9906
9907// ── dns_servers ───────────────────────────────────────────────────────────────
9908
9909fn inspect_dns_servers() -> Result<String, String> {
9910    let mut out = String::from("Host inspection: dns_servers\n\n");
9911
9912    #[cfg(target_os = "windows")]
9913    {
9914        let dns_out = Command::new("powershell")
9915            .args([
9916                "-NoProfile", "-NonInteractive", "-Command",
9917                "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \"  $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9918            ])
9919            .output()
9920            .ok()
9921            .and_then(|o| String::from_utf8(o.stdout).ok())
9922            .unwrap_or_default();
9923
9924        out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9925        if dns_out.trim().is_empty() {
9926            out.push_str("  (unable to retrieve)\n");
9927        } else {
9928            for line in dns_out.lines() {
9929                if line.trim().is_empty() {
9930                    continue;
9931                }
9932                let mut annotation = "";
9933                if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9934                    annotation = "  <- Google Public DNS";
9935                } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9936                    annotation = "  <- Cloudflare DNS";
9937                } else if line.contains("9.9.9.9") {
9938                    annotation = "  <- Quad9";
9939                } else if line.contains("208.67.222") || line.contains("208.67.220") {
9940                    annotation = "  <- OpenDNS";
9941                }
9942                out.push_str(line);
9943                out.push_str(annotation);
9944                out.push('\n');
9945            }
9946        }
9947
9948        let doh_out = Command::new("powershell")
9949            .args([
9950                "-NoProfile", "-NonInteractive", "-Command",
9951                "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \"  $($_.ServerAddress): $($_.DohTemplate)\" }",
9952            ])
9953            .output()
9954            .ok()
9955            .and_then(|o| String::from_utf8(o.stdout).ok())
9956            .unwrap_or_default();
9957
9958        out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9959        if doh_out.trim().is_empty() {
9960            out.push_str("  Not configured (plain DNS).\n");
9961        } else {
9962            out.push_str(doh_out.trim());
9963            out.push('\n');
9964        }
9965
9966        let suffixes = Command::new("powershell")
9967            .args([
9968                "-NoProfile", "-NonInteractive", "-Command",
9969                "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \"  $_\" }",
9970            ])
9971            .output()
9972            .ok()
9973            .and_then(|o| String::from_utf8(o.stdout).ok())
9974            .unwrap_or_default();
9975
9976        if !suffixes.trim().is_empty() {
9977            out.push_str("\n=== DNS Search Suffix List ===\n");
9978            out.push_str(suffixes.trim());
9979            out.push('\n');
9980        }
9981    }
9982
9983    #[cfg(not(target_os = "windows"))]
9984    {
9985        let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9986        out.push_str("=== /etc/resolv.conf ===\n");
9987        if resolv.is_empty() {
9988            out.push_str("  Not found.\n");
9989        } else {
9990            for line in resolv.lines() {
9991                if !line.trim().is_empty() && !line.starts_with('#') {
9992                    out.push_str(&format!("  {}\n", line));
9993                }
9994            }
9995        }
9996        let resolved_out = Command::new("resolvectl")
9997            .args(["status", "--no-pager"])
9998            .output()
9999            .ok()
10000            .and_then(|o| String::from_utf8(o.stdout).ok())
10001            .unwrap_or_default();
10002        if !resolved_out.is_empty() {
10003            out.push_str("\n=== systemd-resolved ===\n");
10004            for line in resolved_out.lines().take(30) {
10005                out.push_str(&format!("  {}\n", line));
10006            }
10007        }
10008    }
10009
10010    Ok(out.trim_end().to_string())
10011}
10012
10013fn inspect_bitlocker() -> Result<String, String> {
10014    let mut out = String::from("Host inspection: bitlocker\n\n");
10015
10016    #[cfg(target_os = "windows")]
10017    {
10018        let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
10019        let output = Command::new("powershell")
10020            .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
10021            .output()
10022            .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
10023
10024        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10025        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
10026
10027        if !stdout.trim().is_empty() {
10028            out.push_str("=== BitLocker Volumes ===\n");
10029            for line in stdout.lines() {
10030                out.push_str(&format!("  {}\n", line));
10031            }
10032        } else if !stderr.trim().is_empty() {
10033            if stderr.contains("Access is denied") {
10034                out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
10035            } else {
10036                out.push_str(&format!(
10037                    "Error retrieving BitLocker info: {}\n",
10038                    stderr.trim()
10039                ));
10040            }
10041        } else {
10042            out.push_str("No BitLocker volumes detected or access denied.\n");
10043        }
10044    }
10045
10046    #[cfg(not(target_os = "windows"))]
10047    {
10048        out.push_str(
10049            "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
10050        );
10051        let lsblk = Command::new("lsblk")
10052            .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
10053            .output()
10054            .ok()
10055            .and_then(|o| String::from_utf8(o.stdout).ok())
10056            .unwrap_or_default();
10057        if lsblk.contains("crypto_LUKS") {
10058            out.push_str("=== LUKS Encrypted Volumes ===\n");
10059            for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
10060                out.push_str(&format!("  {}\n", line));
10061            }
10062        } else {
10063            out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
10064        }
10065    }
10066
10067    Ok(out.trim_end().to_string())
10068}
10069
10070fn inspect_rdp() -> Result<String, String> {
10071    let mut out = String::from("Host inspection: rdp\n\n");
10072
10073    #[cfg(target_os = "windows")]
10074    {
10075        let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
10076        let f_deny = Command::new("powershell")
10077            .args([
10078                "-NoProfile",
10079                "-Command",
10080                &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
10081            ])
10082            .output()
10083            .ok()
10084            .and_then(|o| String::from_utf8(o.stdout).ok())
10085            .unwrap_or_default()
10086            .trim()
10087            .to_string();
10088
10089        let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
10090        out.push_str(&format!("=== RDP Status: {} ===\n", status));
10091
10092        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"])
10093            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
10094        out.push_str(&format!(
10095            "  Port: {}\n",
10096            if port.is_empty() {
10097                "3389 (default)"
10098            } else {
10099                &port
10100            }
10101        ));
10102
10103        let nla = Command::new("powershell")
10104            .args([
10105                "-NoProfile",
10106                "-Command",
10107                &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
10108            ])
10109            .output()
10110            .ok()
10111            .and_then(|o| String::from_utf8(o.stdout).ok())
10112            .unwrap_or_default()
10113            .trim()
10114            .to_string();
10115        out.push_str(&format!(
10116            "  NLA Required: {}\n",
10117            if nla == "1" { "Yes" } else { "No" }
10118        ));
10119
10120        let rdp_tcp_path =
10121            "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp";
10122        let sec_layer = Command::new("powershell")
10123            .args([
10124                "-NoProfile",
10125                "-Command",
10126                &format!("(Get-ItemProperty '{}').SecurityLayer", rdp_tcp_path),
10127            ])
10128            .output()
10129            .ok()
10130            .and_then(|o| String::from_utf8(o.stdout).ok())
10131            .unwrap_or_default()
10132            .trim()
10133            .to_string();
10134        let sec_label = match sec_layer.as_str() {
10135            "0" => "RDP Security (no SSL)",
10136            "1" => "Negotiate (prefer TLS)",
10137            "2" => "SSL/TLS required",
10138            _ => &sec_layer,
10139        };
10140        out.push_str(&format!(
10141            "  Security Layer: {} ({})\n",
10142            sec_layer, sec_label
10143        ));
10144
10145        let enc_level = Command::new("powershell")
10146            .args([
10147                "-NoProfile",
10148                "-Command",
10149                &format!("(Get-ItemProperty '{}').MinEncryptionLevel", rdp_tcp_path),
10150            ])
10151            .output()
10152            .ok()
10153            .and_then(|o| String::from_utf8(o.stdout).ok())
10154            .unwrap_or_default()
10155            .trim()
10156            .to_string();
10157        let enc_label = match enc_level.as_str() {
10158            "1" => "Low",
10159            "2" => "Client Compatible",
10160            "3" => "High",
10161            "4" => "FIPS Compliant",
10162            _ => "Unknown",
10163        };
10164        out.push_str(&format!(
10165            "  Encryption Level: {} ({})\n",
10166            enc_level, enc_label
10167        ));
10168
10169        out.push_str("\n=== Active Sessions ===\n");
10170        let qwinsta = Command::new("qwinsta")
10171            .output()
10172            .ok()
10173            .and_then(|o| String::from_utf8(o.stdout).ok())
10174            .unwrap_or_default();
10175        if qwinsta.trim().is_empty() {
10176            out.push_str("  No active sessions listed.\n");
10177        } else {
10178            for line in qwinsta.lines() {
10179                out.push_str(&format!("  {}\n", line));
10180            }
10181        }
10182
10183        out.push_str("\n=== Firewall Rule Check ===\n");
10184        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))\" }"])
10185            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10186        if fw.trim().is_empty() {
10187            out.push_str("  No enabled RDP firewall rules found.\n");
10188        } else {
10189            out.push_str(fw.trim_end());
10190            out.push('\n');
10191        }
10192    }
10193
10194    #[cfg(not(target_os = "windows"))]
10195    {
10196        out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
10197        let ss = Command::new("ss")
10198            .args(["-tlnp"])
10199            .output()
10200            .ok()
10201            .and_then(|o| String::from_utf8(o.stdout).ok())
10202            .unwrap_or_default();
10203        let matches: Vec<&str> = ss
10204            .lines()
10205            .filter(|l| l.contains(":3389") || l.contains(":590"))
10206            .collect();
10207        if matches.is_empty() {
10208            out.push_str("  No RDP/VNC listeners detected via 'ss'.\n");
10209        } else {
10210            for m in matches {
10211                out.push_str(&format!("  {}\n", m));
10212            }
10213        }
10214    }
10215
10216    Ok(out.trim_end().to_string())
10217}
10218
10219fn inspect_shadow_copies() -> Result<String, String> {
10220    let mut out = String::from("Host inspection: shadow_copies\n\n");
10221
10222    #[cfg(target_os = "windows")]
10223    {
10224        let output = Command::new("vssadmin")
10225            .args(["list", "shadows"])
10226            .output()
10227            .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
10228        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10229
10230        if stdout.contains("No items found") || stdout.trim().is_empty() {
10231            out.push_str("No Volume Shadow Copies found.\n");
10232        } else {
10233            out.push_str("=== Volume Shadow Copies ===\n");
10234            for line in stdout.lines().take(50) {
10235                if line.contains("Creation Time:")
10236                    || line.contains("Contents:")
10237                    || line.contains("Volume Name:")
10238                {
10239                    out.push_str(&format!("  {}\n", line.trim()));
10240                }
10241            }
10242        }
10243
10244        // Most recent snapshot age
10245        let age_script = r#"
10246try {
10247    $snaps = Get-WmiObject Win32_ShadowCopy -ErrorAction SilentlyContinue | Sort-Object InstallDate -Descending
10248    if ($snaps) {
10249        $newest = $snaps[0]
10250        $created = [Management.ManagementDateTimeConverter]::ToDateTime($newest.InstallDate)
10251        $age = [math]::Round(([datetime]::Now - $created).TotalDays, 1)
10252        $count = @($snaps).Count
10253        "Most recent snapshot: $($created.ToString('yyyy-MM-dd HH:mm'))  ($age days ago)  — $count total snapshots"
10254    } else { "No snapshots found via WMI." }
10255} catch { "WMI snapshot query unavailable: $_" }
10256"#;
10257        if let Ok(age_out) = Command::new("powershell")
10258            .args(["-NoProfile", "-Command", age_script])
10259            .output()
10260        {
10261            let age_text = String::from_utf8_lossy(&age_out.stdout).trim().to_string();
10262            if !age_text.is_empty() {
10263                out.push_str("\n=== Snapshot Age ===\n");
10264                out.push_str(&format!("  {}\n", age_text));
10265            }
10266        }
10267
10268        out.push_str("\n=== Shadow Copy Storage ===\n");
10269        let storage_out = Command::new("vssadmin")
10270            .args(["list", "shadowstorage"])
10271            .output()
10272            .ok();
10273        if let Some(o) = storage_out {
10274            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10275            for line in stdout.lines() {
10276                if line.contains("Used Shadow Copy Storage space:")
10277                    || line.contains("Max Shadow Copy Storage space:")
10278                {
10279                    out.push_str(&format!("  {}\n", line.trim()));
10280                }
10281            }
10282        }
10283    }
10284
10285    #[cfg(not(target_os = "windows"))]
10286    {
10287        out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
10288        let lvs = Command::new("lvs")
10289            .output()
10290            .ok()
10291            .and_then(|o| String::from_utf8(o.stdout).ok())
10292            .unwrap_or_default();
10293        if !lvs.is_empty() {
10294            out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
10295            out.push_str(&lvs);
10296        } else {
10297            out.push_str("No LVM volumes detected.\n");
10298        }
10299    }
10300
10301    Ok(out.trim_end().to_string())
10302}
10303
10304fn inspect_pagefile() -> Result<String, String> {
10305    let mut out = String::from("Host inspection: pagefile\n\n");
10306
10307    #[cfg(target_os = "windows")]
10308    {
10309        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)\" }";
10310        let output = Command::new("powershell")
10311            .args(["-NoProfile", "-Command", ps_cmd])
10312            .output()
10313            .ok()
10314            .and_then(|o| String::from_utf8(o.stdout).ok())
10315            .unwrap_or_default();
10316
10317        if output.trim().is_empty() {
10318            out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
10319            let managed = Command::new("powershell")
10320                .args([
10321                    "-NoProfile",
10322                    "-Command",
10323                    "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
10324                ])
10325                .output()
10326                .ok()
10327                .and_then(|o| String::from_utf8(o.stdout).ok())
10328                .unwrap_or_default()
10329                .trim()
10330                .to_string();
10331            out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
10332        } else {
10333            out.push_str("=== Page File Usage ===\n");
10334            out.push_str(&output);
10335        }
10336    }
10337
10338    #[cfg(not(target_os = "windows"))]
10339    {
10340        out.push_str("=== Swap Usage (Linux/macOS) ===\n");
10341        let swap = Command::new("swapon")
10342            .args(["--show"])
10343            .output()
10344            .ok()
10345            .and_then(|o| String::from_utf8(o.stdout).ok())
10346            .unwrap_or_default();
10347        if swap.is_empty() {
10348            let free = Command::new("free")
10349                .args(["-h"])
10350                .output()
10351                .ok()
10352                .and_then(|o| String::from_utf8(o.stdout).ok())
10353                .unwrap_or_default();
10354            out.push_str(&free);
10355        } else {
10356            out.push_str(&swap);
10357        }
10358    }
10359
10360    Ok(out.trim_end().to_string())
10361}
10362
10363fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
10364    let mut out = String::from("Host inspection: windows_features\n\n");
10365
10366    #[cfg(target_os = "windows")]
10367    {
10368        out.push_str("=== Quick Check: Notable Features ===\n");
10369        let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
10370        let output = Command::new("powershell")
10371            .args(["-NoProfile", "-Command", quick_ps])
10372            .output()
10373            .ok();
10374
10375        if let Some(o) = output {
10376            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10377            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10378
10379            if !stdout.trim().is_empty() {
10380                for f in stdout.lines() {
10381                    out.push_str(&format!("  [ENABLED] {}\n", f));
10382                }
10383            } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
10384                out.push_str("  Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
10385            } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
10386                out.push_str(
10387                    "  No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
10388                );
10389            }
10390        }
10391
10392        out.push_str(&format!(
10393            "\n=== All Enabled Features (capped at {}) ===\n",
10394            max_entries
10395        ));
10396        let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
10397        let all_out = Command::new("powershell")
10398            .args(["-NoProfile", "-Command", &all_ps])
10399            .output()
10400            .ok();
10401        if let Some(o) = all_out {
10402            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10403            if !stdout.trim().is_empty() {
10404                out.push_str(&stdout);
10405            }
10406        }
10407    }
10408
10409    #[cfg(not(target_os = "windows"))]
10410    {
10411        let _ = max_entries;
10412        out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
10413    }
10414
10415    Ok(out.trim_end().to_string())
10416}
10417
10418fn inspect_audio(max_entries: usize) -> Result<String, String> {
10419    let mut out = String::from("Host inspection: audio\n\n");
10420
10421    #[cfg(target_os = "windows")]
10422    {
10423        let n = max_entries.clamp(5, 20);
10424        let services = collect_services().unwrap_or_default();
10425        let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
10426        let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
10427
10428        let core_services: Vec<&ServiceEntry> = services
10429            .iter()
10430            .filter(|entry| {
10431                core_service_names
10432                    .iter()
10433                    .any(|name| entry.name.eq_ignore_ascii_case(name))
10434            })
10435            .collect();
10436        let bluetooth_audio_services: Vec<&ServiceEntry> = services
10437            .iter()
10438            .filter(|entry| {
10439                bluetooth_audio_service_names
10440                    .iter()
10441                    .any(|name| entry.name.eq_ignore_ascii_case(name))
10442            })
10443            .collect();
10444
10445        let probe_script = r#"
10446$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
10447    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10448$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10449    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10450$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
10451    Select-Object Name, Status, Manufacturer, PNPDeviceID)
10452[pscustomobject]@{
10453    Media = $media
10454    Endpoints = $endpoints
10455    SoundDevices = $sound
10456} | ConvertTo-Json -Compress -Depth 4
10457"#;
10458        let probe_raw = Command::new("powershell")
10459            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10460            .output()
10461            .ok()
10462            .and_then(|o| String::from_utf8(o.stdout).ok())
10463            .unwrap_or_default();
10464        let probe_loaded = !probe_raw.trim().is_empty();
10465        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10466
10467        let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
10468        let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
10469        let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
10470
10471        let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
10472            .iter()
10473            .filter(|device| !is_microphone_like_name(&device.name))
10474            .collect();
10475        let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
10476            .iter()
10477            .filter(|device| is_microphone_like_name(&device.name))
10478            .collect();
10479        let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
10480            .iter()
10481            .filter(|device| is_bluetooth_like_name(&device.name))
10482            .collect();
10483        let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
10484            .iter()
10485            .filter(|device| windows_device_has_issue(device))
10486            .collect();
10487        let media_problems: Vec<&WindowsPnpDevice> = media_devices
10488            .iter()
10489            .filter(|device| windows_device_has_issue(device))
10490            .collect();
10491        let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
10492            .iter()
10493            .filter(|device| windows_sound_device_has_issue(device))
10494            .collect();
10495
10496        let mut findings = Vec::new();
10497
10498        let stopped_core_services: Vec<&ServiceEntry> = core_services
10499            .iter()
10500            .copied()
10501            .filter(|service| !service_is_running(service))
10502            .collect();
10503        if !stopped_core_services.is_empty() {
10504            let names = stopped_core_services
10505                .iter()
10506                .map(|service| service.name.as_str())
10507                .collect::<Vec<_>>()
10508                .join(", ");
10509            findings.push(AuditFinding {
10510                finding: format!("Core audio services are not running: {names}"),
10511                impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
10512                fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
10513            });
10514        }
10515
10516        if probe_loaded
10517            && endpoints.is_empty()
10518            && media_devices.is_empty()
10519            && sound_devices.is_empty()
10520        {
10521            findings.push(AuditFinding {
10522                finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
10523                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(),
10524                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(),
10525            });
10526        }
10527
10528        if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
10529        {
10530            let mut problem_labels = Vec::new();
10531            problem_labels.extend(
10532                endpoint_problems
10533                    .iter()
10534                    .take(3)
10535                    .map(|device| device.name.clone()),
10536            );
10537            problem_labels.extend(
10538                media_problems
10539                    .iter()
10540                    .take(3)
10541                    .map(|device| device.name.clone()),
10542            );
10543            problem_labels.extend(
10544                sound_problems
10545                    .iter()
10546                    .take(3)
10547                    .map(|device| device.name.clone()),
10548            );
10549            findings.push(AuditFinding {
10550                finding: format!(
10551                    "Windows reports audio device issues for: {}",
10552                    problem_labels.join(", ")
10553                ),
10554                impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
10555                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(),
10556            });
10557        }
10558
10559        let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
10560            .iter()
10561            .copied()
10562            .filter(|service| !service_is_running(service))
10563            .collect();
10564        if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
10565            let names = stopped_bt_audio_services
10566                .iter()
10567                .map(|service| service.name.as_str())
10568                .collect::<Vec<_>>()
10569                .join(", ");
10570            findings.push(AuditFinding {
10571                finding: format!(
10572                    "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
10573                ),
10574                impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
10575                fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
10576            });
10577        }
10578
10579        out.push_str("=== Findings ===\n");
10580        if findings.is_empty() {
10581            out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
10582            out.push_str("  Impact: Playback and recording look structurally present from this inspection pass.\n");
10583            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");
10584        } else {
10585            for finding in &findings {
10586                out.push_str(&format!("- Finding: {}\n", finding.finding));
10587                out.push_str(&format!("  Impact: {}\n", finding.impact));
10588                out.push_str(&format!("  Fix: {}\n", finding.fix));
10589            }
10590        }
10591
10592        out.push_str("\n=== Audio services ===\n");
10593        if core_services.is_empty() && bluetooth_audio_services.is_empty() {
10594            out.push_str(
10595                "- No Windows audio services were retrieved from the service inventory.\n",
10596            );
10597        } else {
10598            for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
10599                out.push_str(&format!(
10600                    "- {} | Status: {} | Startup: {}\n",
10601                    service.name,
10602                    service.status,
10603                    service.startup.as_deref().unwrap_or("Unknown")
10604                ));
10605            }
10606        }
10607
10608        out.push_str("\n=== Playback and recording endpoints ===\n");
10609        if !probe_loaded {
10610            out.push_str("- Windows endpoint inventory probe returned no data.\n");
10611        } else if endpoints.is_empty() {
10612            out.push_str("- No audio endpoints detected.\n");
10613        } else {
10614            out.push_str(&format!(
10615                "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
10616                playback_endpoints.len(),
10617                recording_endpoints.len()
10618            ));
10619            for device in playback_endpoints.iter().take(n) {
10620                out.push_str(&format!(
10621                    "- [PLAYBACK] {} | Status: {}{}\n",
10622                    device.name,
10623                    device.status,
10624                    device
10625                        .problem
10626                        .filter(|problem| *problem != 0)
10627                        .map(|problem| format!(" | ProblemCode: {problem}"))
10628                        .unwrap_or_default()
10629                ));
10630            }
10631            for device in recording_endpoints.iter().take(n) {
10632                out.push_str(&format!(
10633                    "- [MIC] {} | Status: {}{}\n",
10634                    device.name,
10635                    device.status,
10636                    device
10637                        .problem
10638                        .filter(|problem| *problem != 0)
10639                        .map(|problem| format!(" | ProblemCode: {problem}"))
10640                        .unwrap_or_default()
10641                ));
10642            }
10643        }
10644
10645        out.push_str("\n=== Sound hardware devices ===\n");
10646        if sound_devices.is_empty() {
10647            out.push_str("- No Win32_SoundDevice entries were returned.\n");
10648        } else {
10649            for device in sound_devices.iter().take(n) {
10650                out.push_str(&format!(
10651                    "- {} | Status: {}{}\n",
10652                    device.name,
10653                    device.status,
10654                    device
10655                        .manufacturer
10656                        .as_deref()
10657                        .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
10658                        .unwrap_or_default()
10659                ));
10660            }
10661        }
10662
10663        out.push_str("\n=== Media-class device inventory ===\n");
10664        if media_devices.is_empty() {
10665            out.push_str("- No media-class PnP devices were returned.\n");
10666        } else {
10667            for device in media_devices.iter().take(n) {
10668                out.push_str(&format!(
10669                    "- {} | Status: {}{}\n",
10670                    device.name,
10671                    device.status,
10672                    device
10673                        .class_name
10674                        .as_deref()
10675                        .map(|class_name| format!(" | Class: {class_name}"))
10676                        .unwrap_or_default()
10677                ));
10678            }
10679        }
10680    }
10681
10682    #[cfg(not(target_os = "windows"))]
10683    {
10684        let _ = max_entries;
10685        out.push_str(
10686            "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10687        );
10688        out.push_str(
10689            "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10690        );
10691    }
10692
10693    Ok(out.trim_end().to_string())
10694}
10695
10696fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10697    let mut out = String::from("Host inspection: bluetooth\n\n");
10698
10699    #[cfg(target_os = "windows")]
10700    {
10701        let n = max_entries.clamp(5, 20);
10702        let services = collect_services().unwrap_or_default();
10703        let bluetooth_services: Vec<&ServiceEntry> = services
10704            .iter()
10705            .filter(|entry| {
10706                entry.name.eq_ignore_ascii_case("bthserv")
10707                    || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10708                    || entry.name.eq_ignore_ascii_case("BTAGService")
10709                    || entry.name.starts_with("BluetoothUserService")
10710                    || entry
10711                        .display_name
10712                        .as_deref()
10713                        .unwrap_or("")
10714                        .to_ascii_lowercase()
10715                        .contains("bluetooth")
10716            })
10717            .collect();
10718
10719        let probe_script = r#"
10720$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10721    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10722$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10723    Where-Object {
10724        $_.Class -eq 'Bluetooth' -or
10725        $_.FriendlyName -match 'Bluetooth' -or
10726        $_.InstanceId -like 'BTH*'
10727    } |
10728    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10729$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10730    Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10731    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10732[pscustomobject]@{
10733    Radios = $radios
10734    Devices = $devices
10735    AudioEndpoints = $audio
10736} | ConvertTo-Json -Compress -Depth 4
10737"#;
10738        let probe_raw = Command::new("powershell")
10739            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10740            .output()
10741            .ok()
10742            .and_then(|o| String::from_utf8(o.stdout).ok())
10743            .unwrap_or_default();
10744        let probe_loaded = !probe_raw.trim().is_empty();
10745        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10746
10747        let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10748        let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10749        let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10750        let radio_problems: Vec<&WindowsPnpDevice> = radios
10751            .iter()
10752            .filter(|device| windows_device_has_issue(device))
10753            .collect();
10754        let device_problems: Vec<&WindowsPnpDevice> = devices
10755            .iter()
10756            .filter(|device| windows_device_has_issue(device))
10757            .collect();
10758
10759        let mut findings = Vec::new();
10760
10761        if probe_loaded && radios.is_empty() {
10762            findings.push(AuditFinding {
10763                finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10764                impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10765                fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10766            });
10767        }
10768
10769        let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10770            .iter()
10771            .copied()
10772            .filter(|service| !service_is_running(service))
10773            .collect();
10774        if !stopped_bluetooth_services.is_empty() {
10775            let names = stopped_bluetooth_services
10776                .iter()
10777                .map(|service| service.name.as_str())
10778                .collect::<Vec<_>>()
10779                .join(", ");
10780            findings.push(AuditFinding {
10781                finding: format!("Bluetooth-related services are not fully running: {names}"),
10782                impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10783                fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10784            });
10785        }
10786
10787        if !radio_problems.is_empty() || !device_problems.is_empty() {
10788            let problem_labels = radio_problems
10789                .iter()
10790                .chain(device_problems.iter())
10791                .take(5)
10792                .map(|device| device.name.as_str())
10793                .collect::<Vec<_>>()
10794                .join(", ");
10795            findings.push(AuditFinding {
10796                finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10797                impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10798                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(),
10799            });
10800        }
10801
10802        if !audio_endpoints.is_empty()
10803            && bluetooth_services
10804                .iter()
10805                .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10806            && bluetooth_services
10807                .iter()
10808                .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10809                .any(|service| !service_is_running(service))
10810        {
10811            findings.push(AuditFinding {
10812                finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10813                impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10814                fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10815            });
10816        }
10817
10818        out.push_str("=== Findings ===\n");
10819        if findings.is_empty() {
10820            out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10821            out.push_str("  Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10822            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");
10823        } else {
10824            for finding in &findings {
10825                out.push_str(&format!("- Finding: {}\n", finding.finding));
10826                out.push_str(&format!("  Impact: {}\n", finding.impact));
10827                out.push_str(&format!("  Fix: {}\n", finding.fix));
10828            }
10829        }
10830
10831        out.push_str("\n=== Bluetooth services ===\n");
10832        if bluetooth_services.is_empty() {
10833            out.push_str(
10834                "- No Bluetooth-related services were retrieved from the service inventory.\n",
10835            );
10836        } else {
10837            for service in bluetooth_services.iter().take(n) {
10838                out.push_str(&format!(
10839                    "- {} | Status: {} | Startup: {}\n",
10840                    service.name,
10841                    service.status,
10842                    service.startup.as_deref().unwrap_or("Unknown")
10843                ));
10844            }
10845        }
10846
10847        out.push_str("\n=== Bluetooth radios and adapters ===\n");
10848        if !probe_loaded {
10849            out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10850        } else if radios.is_empty() {
10851            out.push_str("- No Bluetooth radios detected.\n");
10852        } else {
10853            for device in radios.iter().take(n) {
10854                out.push_str(&format!(
10855                    "- {} | Status: {}{}\n",
10856                    device.name,
10857                    device.status,
10858                    device
10859                        .problem
10860                        .filter(|problem| *problem != 0)
10861                        .map(|problem| format!(" | ProblemCode: {problem}"))
10862                        .unwrap_or_default()
10863                ));
10864            }
10865        }
10866
10867        out.push_str("\n=== Bluetooth-associated devices ===\n");
10868        if devices.is_empty() {
10869            out.push_str("- No Bluetooth-associated device nodes detected.\n");
10870        } else {
10871            for device in devices.iter().take(n) {
10872                out.push_str(&format!(
10873                    "- {} | Status: {}{}\n",
10874                    device.name,
10875                    device.status,
10876                    device
10877                        .class_name
10878                        .as_deref()
10879                        .map(|class_name| format!(" | Class: {class_name}"))
10880                        .unwrap_or_default()
10881                ));
10882            }
10883        }
10884
10885        out.push_str("\n=== Bluetooth audio endpoints ===\n");
10886        if audio_endpoints.is_empty() {
10887            out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10888        } else {
10889            for device in audio_endpoints.iter().take(n) {
10890                out.push_str(&format!(
10891                    "- {} | Status: {}{}\n",
10892                    device.name,
10893                    device.status,
10894                    device
10895                        .instance_id
10896                        .as_deref()
10897                        .map(|instance_id| format!(" | Instance: {instance_id}"))
10898                        .unwrap_or_default()
10899                ));
10900            }
10901        }
10902    }
10903
10904    #[cfg(not(target_os = "windows"))]
10905    {
10906        let _ = max_entries;
10907        out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10908        out.push_str(
10909            "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10910        );
10911    }
10912
10913    Ok(out.trim_end().to_string())
10914}
10915
10916fn inspect_printers(max_entries: usize) -> Result<String, String> {
10917    let mut out = String::from("Host inspection: printers\n\n");
10918
10919    #[cfg(target_os = "windows")]
10920    {
10921        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)])
10922            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10923        if list.trim().is_empty() {
10924            out.push_str("No printers detected.\n");
10925        } else {
10926            out.push_str("=== Installed Printers ===\n");
10927            out.push_str(&list);
10928        }
10929
10930        let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \"  [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10931            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10932        if !jobs.trim().is_empty() {
10933            out.push_str("\n=== Active Print Jobs ===\n");
10934            out.push_str(&jobs);
10935        }
10936    }
10937
10938    #[cfg(not(target_os = "windows"))]
10939    {
10940        let _ = max_entries;
10941        out.push_str("Checking LPSTAT for printers...\n");
10942        let lpstat = Command::new("lpstat")
10943            .args(["-p", "-d"])
10944            .output()
10945            .ok()
10946            .and_then(|o| String::from_utf8(o.stdout).ok())
10947            .unwrap_or_default();
10948        if lpstat.is_empty() {
10949            out.push_str("  No CUPS/LP printers found.\n");
10950        } else {
10951            out.push_str(&lpstat);
10952        }
10953    }
10954
10955    Ok(out.trim_end().to_string())
10956}
10957
10958fn inspect_winrm() -> Result<String, String> {
10959    let mut out = String::from("Host inspection: winrm\n\n");
10960
10961    #[cfg(target_os = "windows")]
10962    {
10963        let svc = Command::new("powershell")
10964            .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10965            .output()
10966            .ok()
10967            .and_then(|o| String::from_utf8(o.stdout).ok())
10968            .unwrap_or_default()
10969            .trim()
10970            .to_string();
10971        out.push_str(&format!(
10972            "WinRM Service Status: {}\n\n",
10973            if svc.is_empty() { "NOT_FOUND" } else { &svc }
10974        ));
10975
10976        out.push_str("=== WinRM Listeners ===\n");
10977        let output = Command::new("powershell")
10978            .args([
10979                "-NoProfile",
10980                "-Command",
10981                "winrm enumerate winrm/config/listener 2>$null",
10982            ])
10983            .output()
10984            .ok();
10985        if let Some(o) = output {
10986            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10987            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10988
10989            if !stdout.trim().is_empty() {
10990                for line in stdout.lines() {
10991                    if line.contains("Address =")
10992                        || line.contains("Transport =")
10993                        || line.contains("Port =")
10994                    {
10995                        out.push_str(&format!("  {}\n", line.trim()));
10996                    }
10997                }
10998            } else if stderr.contains("Access is denied") {
10999                out.push_str("  Error: Access denied to WinRM configuration.\n");
11000            } else {
11001                out.push_str("  No listeners configured.\n");
11002            }
11003        }
11004
11005        out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
11006        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))\" }"])
11007            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11008        if test_out.trim().is_empty() {
11009            out.push_str("  WinRM not responding to local WS-Man requests.\n");
11010        } else {
11011            out.push_str(&test_out);
11012        }
11013    }
11014
11015    #[cfg(not(target_os = "windows"))]
11016    {
11017        out.push_str(
11018            "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
11019        );
11020        let ss = Command::new("ss")
11021            .args(["-tln"])
11022            .output()
11023            .ok()
11024            .and_then(|o| String::from_utf8(o.stdout).ok())
11025            .unwrap_or_default();
11026        if ss.contains(":5985") || ss.contains(":5986") {
11027            out.push_str("  WinRM ports (5985/5986) are listening.\n");
11028        } else {
11029            out.push_str("  WinRM ports not detected.\n");
11030        }
11031    }
11032
11033    Ok(out.trim_end().to_string())
11034}
11035
11036fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
11037    let mut out = String::from("Host inspection: network_stats\n\n");
11038
11039    #[cfg(target_os = "windows")]
11040    {
11041        let ps_cmd = format!(
11042            "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
11043             Start-Sleep -Milliseconds 250; \
11044             $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
11045             $s2 | ForEach-Object {{ \
11046                $name = $_.Name; \
11047                $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
11048                if ($prev) {{ \
11049                    $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
11050                    $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
11051                    $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
11052                    $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
11053                    $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
11054                    $tt = [math]::Round($_.SentBytes / 1MB, 2); \
11055                    \"  $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
11056                }} \
11057             }}",
11058            max_entries
11059        );
11060        let output = Command::new("powershell")
11061            .args(["-NoProfile", "-Command", &ps_cmd])
11062            .output()
11063            .ok()
11064            .and_then(|o| String::from_utf8(o.stdout).ok())
11065            .unwrap_or_default();
11066        if output.trim().is_empty() {
11067            out.push_str("No network adapter statistics available.\n");
11068        } else {
11069            out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
11070            out.push_str(&output);
11071        }
11072
11073        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)\" } }"])
11074            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11075        if !discards.trim().is_empty() {
11076            out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
11077            out.push_str(&discards);
11078        }
11079    }
11080
11081    #[cfg(not(target_os = "windows"))]
11082    {
11083        let _ = max_entries;
11084        out.push_str("=== Network Stats (ip -s link) ===\n");
11085        let ip_s = Command::new("ip")
11086            .args(["-s", "link"])
11087            .output()
11088            .ok()
11089            .and_then(|o| String::from_utf8(o.stdout).ok())
11090            .unwrap_or_default();
11091        if ip_s.is_empty() {
11092            let netstat = Command::new("netstat")
11093                .args(["-i"])
11094                .output()
11095                .ok()
11096                .and_then(|o| String::from_utf8(o.stdout).ok())
11097                .unwrap_or_default();
11098            out.push_str(&netstat);
11099        } else {
11100            out.push_str(&ip_s);
11101        }
11102    }
11103
11104    Ok(out.trim_end().to_string())
11105}
11106
11107fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
11108    let mut out = String::from("Host inspection: udp_ports\n\n");
11109
11110    #[cfg(target_os = "windows")]
11111    {
11112        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);
11113        let output = Command::new("powershell")
11114            .args(["-NoProfile", "-Command", &ps_cmd])
11115            .output()
11116            .ok();
11117
11118        if let Some(o) = output {
11119            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11120            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11121
11122            if !stdout.trim().is_empty() {
11123                out.push_str("=== UDP Listeners (Local:Port) ===\n");
11124                for line in stdout.lines() {
11125                    let mut note = "";
11126                    if line.contains(":53 ") {
11127                        note = " [DNS]";
11128                    } else if line.contains(":67 ") || line.contains(":68 ") {
11129                        note = " [DHCP]";
11130                    } else if line.contains(":123 ") {
11131                        note = " [NTP]";
11132                    } else if line.contains(":161 ") {
11133                        note = " [SNMP]";
11134                    } else if line.contains(":1900 ") {
11135                        note = " [SSDP/UPnP]";
11136                    } else if line.contains(":5353 ") {
11137                        note = " [mDNS]";
11138                    }
11139
11140                    out.push_str(&format!("{}{}\n", line, note));
11141                }
11142            } else if stderr.contains("Access is denied") {
11143                out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
11144            } else {
11145                out.push_str("No UDP listeners detected.\n");
11146            }
11147        }
11148    }
11149
11150    #[cfg(not(target_os = "windows"))]
11151    {
11152        let ss_out = Command::new("ss")
11153            .args(["-ulnp"])
11154            .output()
11155            .ok()
11156            .and_then(|o| String::from_utf8(o.stdout).ok())
11157            .unwrap_or_default();
11158        out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
11159        if ss_out.is_empty() {
11160            let netstat_out = Command::new("netstat")
11161                .args(["-ulnp"])
11162                .output()
11163                .ok()
11164                .and_then(|o| String::from_utf8(o.stdout).ok())
11165                .unwrap_or_default();
11166            if netstat_out.is_empty() {
11167                out.push_str("  Neither 'ss' nor 'netstat' available.\n");
11168            } else {
11169                for line in netstat_out.lines().take(max_entries) {
11170                    out.push_str(&format!("  {}\n", line));
11171                }
11172            }
11173        } else {
11174            for line in ss_out.lines().take(max_entries) {
11175                out.push_str(&format!("  {}\n", line));
11176            }
11177        }
11178    }
11179
11180    Ok(out.trim_end().to_string())
11181}
11182
11183fn inspect_gpo() -> Result<String, String> {
11184    let mut out = String::from("Host inspection: gpo\n\n");
11185
11186    #[cfg(target_os = "windows")]
11187    {
11188        let output = Command::new("gpresult")
11189            .args(["/r", "/scope", "computer"])
11190            .output()
11191            .ok();
11192
11193        if let Some(o) = output {
11194            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11195            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11196
11197            if stdout.contains("Applied Group Policy Objects") {
11198                out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
11199                let mut capture = false;
11200                for line in stdout.lines() {
11201                    if line.contains("Applied Group Policy Objects") {
11202                        capture = true;
11203                    } else if capture && line.contains("The following GPOs were not applied") {
11204                        break;
11205                    }
11206                    if capture && !line.trim().is_empty() {
11207                        out.push_str(&format!("  {}\n", line.trim()));
11208                    }
11209                }
11210            } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
11211                out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
11212            } else {
11213                out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
11214            }
11215        }
11216    }
11217
11218    #[cfg(not(target_os = "windows"))]
11219    {
11220        out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
11221    }
11222
11223    Ok(out.trim_end().to_string())
11224}
11225
11226fn inspect_certificates(max_entries: usize) -> Result<String, String> {
11227    let mut out = String::from("Host inspection: certificates\n\n");
11228
11229    #[cfg(target_os = "windows")]
11230    {
11231        let ps_cmd = format!(
11232            "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
11233                $days = ($_.NotAfter - (Get-Date)).Days; \
11234                $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
11235                \"  $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
11236            }}", 
11237            max_entries
11238        );
11239        let output = Command::new("powershell")
11240            .args(["-NoProfile", "-Command", &ps_cmd])
11241            .output()
11242            .ok();
11243
11244        if let Some(o) = output {
11245            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11246            if !stdout.trim().is_empty() {
11247                out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
11248                out.push_str(&stdout);
11249            } else {
11250                out.push_str("No certificates found in the Local Machine Personal store.\n");
11251            }
11252        }
11253    }
11254
11255    #[cfg(not(target_os = "windows"))]
11256    {
11257        let _ = max_entries;
11258        out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
11259        // Check standard cert locations
11260        for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
11261            if Path::new(path).exists() {
11262                out.push_str(&format!("  Cert directory found: {}\n", path));
11263            }
11264        }
11265    }
11266
11267    Ok(out.trim_end().to_string())
11268}
11269
11270fn inspect_integrity() -> Result<String, String> {
11271    let mut out = String::from("Host inspection: integrity\n\n");
11272
11273    #[cfg(target_os = "windows")]
11274    {
11275        let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
11276        let output = Command::new("powershell")
11277            .args(["-NoProfile", "-Command", &ps_cmd])
11278            .output()
11279            .ok();
11280
11281        if let Some(o) = output {
11282            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11283            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11284                out.push_str("=== Windows Component Store Health (CBS) ===\n");
11285                let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
11286                let repair = val
11287                    .get("AutoRepairNeeded")
11288                    .and_then(|v| v.as_u64())
11289                    .unwrap_or(0);
11290
11291                out.push_str(&format!(
11292                    "  Corruption Detected: {}\n",
11293                    if corrupt != 0 {
11294                        "YES (SFC/DISM recommended)"
11295                    } else {
11296                        "No"
11297                    }
11298                ));
11299                out.push_str(&format!(
11300                    "  Auto-Repair Needed: {}\n",
11301                    if repair != 0 { "YES" } else { "No" }
11302                ));
11303
11304                if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
11305                    out.push_str(&format!("  Last Repair Attempt: (Raw code: {})\n", last));
11306                }
11307            } else {
11308                out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
11309            }
11310        }
11311
11312        if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
11313            out.push_str(
11314                "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
11315            );
11316        }
11317    }
11318
11319    #[cfg(not(target_os = "windows"))]
11320    {
11321        out.push_str("System integrity check (Linux)\n\n");
11322        let pkg_check = Command::new("rpm")
11323            .args(["-Va"])
11324            .output()
11325            .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
11326            .ok();
11327        if let Some(o) = pkg_check {
11328            out.push_str("  Package verification system active.\n");
11329            if o.status.success() {
11330                out.push_str("  No major package integrity issues detected.\n");
11331            }
11332        }
11333    }
11334
11335    Ok(out.trim_end().to_string())
11336}
11337
11338fn inspect_domain() -> Result<String, String> {
11339    let mut out = String::from("Host inspection: domain\n\n");
11340
11341    #[cfg(target_os = "windows")]
11342    {
11343        out.push_str("=== Windows Domain / Workgroup Identity ===\n");
11344        let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
11345        let output = Command::new("powershell")
11346            .args(["-NoProfile", "-Command", &ps_cmd])
11347            .output()
11348            .ok();
11349
11350        if let Some(o) = output {
11351            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11352            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11353                let part_of_domain = val
11354                    .get("PartOfDomain")
11355                    .and_then(|v| v.as_bool())
11356                    .unwrap_or(false);
11357                let domain = val
11358                    .get("Domain")
11359                    .and_then(|v| v.as_str())
11360                    .unwrap_or("Unknown");
11361                let workgroup = val
11362                    .get("Workgroup")
11363                    .and_then(|v| v.as_str())
11364                    .unwrap_or("Unknown");
11365
11366                out.push_str(&format!(
11367                    "  Join Status: {}\n",
11368                    if part_of_domain {
11369                        "DOMAIN JOINED"
11370                    } else {
11371                        "WORKGROUP"
11372                    }
11373                ));
11374                if part_of_domain {
11375                    out.push_str(&format!("  Active Directory Domain: {}\n", domain));
11376                } else {
11377                    out.push_str(&format!("  Workgroup Name: {}\n", workgroup));
11378                }
11379
11380                if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
11381                    out.push_str(&format!("  NetBIOS Name: {}\n", name));
11382                }
11383            } else {
11384                out.push_str("  Domain identity data unavailable from WMI.\n");
11385            }
11386        } else {
11387            out.push_str("  Domain identity data unavailable from WMI.\n");
11388        }
11389    }
11390
11391    #[cfg(not(target_os = "windows"))]
11392    {
11393        let domainname = Command::new("domainname")
11394            .output()
11395            .ok()
11396            .and_then(|o| String::from_utf8(o.stdout).ok())
11397            .unwrap_or_default();
11398        out.push_str("=== Linux Domain Identity ===\n");
11399        if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
11400            out.push_str(&format!("  NIS/YP Domain: {}\n", domainname.trim()));
11401        } else {
11402            out.push_str("  No NIS domain configured.\n");
11403        }
11404    }
11405
11406    Ok(out.trim_end().to_string())
11407}
11408
11409fn inspect_device_health() -> Result<String, String> {
11410    let mut out = String::from("Host inspection: device_health\n\n");
11411
11412    #[cfg(target_os = "windows")]
11413    {
11414        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)\" }";
11415        let output = Command::new("powershell")
11416            .args(["-NoProfile", "-Command", ps_cmd])
11417            .output()
11418            .ok()
11419            .and_then(|o| String::from_utf8(o.stdout).ok())
11420            .unwrap_or_default();
11421
11422        if output.trim().is_empty() {
11423            out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
11424        } else {
11425            out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
11426            out.push_str(&output);
11427            out.push_str(
11428                "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
11429            );
11430        }
11431    }
11432
11433    #[cfg(not(target_os = "windows"))]
11434    {
11435        out.push_str("Checking dmesg for hardware errors...\n");
11436        let dmesg = Command::new("dmesg")
11437            .args(["--level=err,crit,alert"])
11438            .output()
11439            .ok()
11440            .and_then(|o| String::from_utf8(o.stdout).ok())
11441            .unwrap_or_default();
11442        if dmesg.is_empty() {
11443            out.push_str("  No critical hardware errors found in dmesg.\n");
11444        } else {
11445            out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
11446        }
11447    }
11448
11449    Ok(out.trim_end().to_string())
11450}
11451
11452fn inspect_drivers(max_entries: usize) -> Result<String, String> {
11453    let mut out = String::from("Host inspection: drivers\n\n");
11454
11455    #[cfg(target_os = "windows")]
11456    {
11457        out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
11458        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);
11459        let output = Command::new("powershell")
11460            .args(["-NoProfile", "-Command", &ps_cmd])
11461            .output()
11462            .ok()
11463            .and_then(|o| String::from_utf8(o.stdout).ok())
11464            .unwrap_or_default();
11465
11466        if output.trim().is_empty() {
11467            out.push_str("  No drivers retrieved via WMI.\n");
11468        } else {
11469            out.push_str(&output);
11470        }
11471    }
11472
11473    #[cfg(not(target_os = "windows"))]
11474    {
11475        out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
11476        let lsmod = Command::new("lsmod")
11477            .output()
11478            .ok()
11479            .and_then(|o| String::from_utf8(o.stdout).ok())
11480            .unwrap_or_default();
11481        out.push_str(
11482            &lsmod
11483                .lines()
11484                .take(max_entries)
11485                .collect::<Vec<_>>()
11486                .join("\n"),
11487        );
11488    }
11489
11490    Ok(out.trim_end().to_string())
11491}
11492
11493fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
11494    let mut out = String::from("Host inspection: peripherals\n\n");
11495
11496    #[cfg(target_os = "windows")]
11497    {
11498        let _ = max_entries;
11499        out.push_str("=== USB Controllers & Hubs ===\n");
11500        let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \"  $($_.Name) ($($_.Status))\" }"])
11501            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11502        out.push_str(if usb.is_empty() {
11503            "  None detected.\n"
11504        } else {
11505            &usb
11506        });
11507
11508        out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
11509        let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \"  [KB] $($_.Name) ($($_.Status))\" }"])
11510            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11511        let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \"  [PTR] $($_.Name) ($($_.Status))\" }"])
11512            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11513        out.push_str(&kb);
11514        out.push_str(&mouse);
11515
11516        out.push_str("\n=== Connected Monitors (WMI) ===\n");
11517        let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \"  Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
11518            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11519        out.push_str(if mon.is_empty() {
11520            "  No active monitors identified via WMI.\n"
11521        } else {
11522            &mon
11523        });
11524    }
11525
11526    #[cfg(not(target_os = "windows"))]
11527    {
11528        out.push_str("=== Connected USB Devices (lsusb) ===\n");
11529        let lsusb = Command::new("lsusb")
11530            .output()
11531            .ok()
11532            .and_then(|o| String::from_utf8(o.stdout).ok())
11533            .unwrap_or_default();
11534        out.push_str(
11535            &lsusb
11536                .lines()
11537                .take(max_entries)
11538                .collect::<Vec<_>>()
11539                .join("\n"),
11540        );
11541    }
11542
11543    Ok(out.trim_end().to_string())
11544}
11545
11546fn inspect_sessions(max_entries: usize) -> Result<String, String> {
11547    let mut out = String::from("Host inspection: sessions\n\n");
11548
11549    #[cfg(target_os = "windows")]
11550    {
11551        out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
11552        let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
11553    "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
11554}"#;
11555        if let Ok(o) = Command::new("powershell")
11556            .args(["-NoProfile", "-Command", script])
11557            .output()
11558        {
11559            let text = String::from_utf8_lossy(&o.stdout);
11560            let lines: Vec<&str> = text.lines().collect();
11561            if lines.is_empty() {
11562                out.push_str("  No active logon sessions enumerated via WMI.\n");
11563            } else {
11564                for line in lines
11565                    .iter()
11566                    .take(max_entries)
11567                    .filter(|l| !l.trim().is_empty())
11568                {
11569                    let parts: Vec<&str> = line.trim().split('|').collect();
11570                    if parts.len() == 4 {
11571                        let logon_type = match parts[2] {
11572                            "2" => "Interactive",
11573                            "3" => "Network",
11574                            "4" => "Batch",
11575                            "5" => "Service",
11576                            "7" => "Unlock",
11577                            "8" => "NetworkCleartext",
11578                            "9" => "NewCredentials",
11579                            "10" => "RemoteInteractive",
11580                            "11" => "CachedInteractive",
11581                            _ => "Other",
11582                        };
11583                        out.push_str(&format!(
11584                            "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
11585                            parts[0], logon_type, parts[1], parts[3]
11586                        ));
11587                    }
11588                }
11589            }
11590        } else {
11591            out.push_str("  Active logon session data unavailable from WMI.\n");
11592        }
11593    }
11594
11595    #[cfg(not(target_os = "windows"))]
11596    {
11597        out.push_str("=== Logged-in Users (who) ===\n");
11598        let who = Command::new("who")
11599            .output()
11600            .ok()
11601            .and_then(|o| String::from_utf8(o.stdout).ok())
11602            .unwrap_or_default();
11603        out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
11604    }
11605
11606    Ok(out.trim_end().to_string())
11607}
11608
11609async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
11610    let mut out = String::from("Host inspection: disk_benchmark\n\n");
11611    let mut final_path = path;
11612
11613    if !final_path.exists() {
11614        if let Ok(current_exe) = std::env::current_exe() {
11615            out.push_str(&format!(
11616                "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
11617                final_path.display()
11618            ));
11619            final_path = current_exe;
11620        } else {
11621            return Err(format!("Target not found: {}", final_path.display()));
11622        }
11623    }
11624
11625    let target = if final_path.is_dir() {
11626        // Find a representative file to read
11627        let mut target_file = final_path.join("Cargo.toml");
11628        if !target_file.exists() {
11629            target_file = final_path.join("README.md");
11630        }
11631        if !target_file.exists() {
11632            return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
11633        }
11634        target_file
11635    } else {
11636        final_path
11637    };
11638
11639    out.push_str(&format!("Target: {}\n", target.display()));
11640    out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
11641
11642    #[cfg(target_os = "windows")]
11643    {
11644        let script = format!(
11645            r#"
11646$target = "{}"
11647if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
11648
11649$diskQueue = @()
11650$readStats = @()
11651$startTime = Get-Date
11652$duration = 5
11653
11654# Background reader job
11655$job = Start-Job -ScriptBlock {{
11656    param($t, $d)
11657    $stop = (Get-Date).AddSeconds($d)
11658    while ((Get-Date) -lt $stop) {{
11659        try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11660    }}
11661}} -ArgumentList $target, $duration
11662
11663# Metrics collector loop
11664$stopTime = (Get-Date).AddSeconds($duration)
11665while ((Get-Date) -lt $stopTime) {{
11666    $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11667    if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11668    
11669    $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11670    if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11671    
11672    Start-Sleep -Milliseconds 250
11673}}
11674
11675Stop-Job $job
11676Receive-Job $job | Out-Null
11677Remove-Job $job
11678
11679$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11680$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11681$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11682
11683"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11684"#,
11685            target.display()
11686        );
11687
11688        let output = Command::new("powershell")
11689            .args(["-NoProfile", "-Command", &script])
11690            .output()
11691            .map_err(|e| format!("Benchmark failed: {e}"))?;
11692
11693        let raw = String::from_utf8_lossy(&output.stdout);
11694        let text = raw.trim();
11695
11696        if text.starts_with("ERROR") {
11697            return Err(text.to_string());
11698        }
11699
11700        let mut lines = text.lines();
11701        if let Some(metrics_line) = lines.next() {
11702            let parts: Vec<&str> = metrics_line.split('|').collect();
11703            let mut avg_q = "unknown".to_string();
11704            let mut max_q = "unknown".to_string();
11705            let mut avg_r = "unknown".to_string();
11706
11707            for p in parts {
11708                if let Some((k, v)) = p.split_once(':') {
11709                    match k {
11710                        "AVG_Q" => avg_q = v.to_string(),
11711                        "MAX_Q" => max_q = v.to_string(),
11712                        "AVG_R" => avg_r = v.to_string(),
11713                        _ => {}
11714                    }
11715                }
11716            }
11717
11718            out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11719            out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
11720            out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
11721            out.push_str(&format!("- Disk Throughput (Avg):  {} reads/sec\n", avg_r));
11722            out.push_str("\nVerdict: ");
11723            let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11724            if q_num > 1.0 {
11725                out.push_str(
11726                    "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11727                );
11728            } else if q_num > 0.1 {
11729                out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11730            } else {
11731                out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11732            }
11733        }
11734    }
11735
11736    #[cfg(not(target_os = "windows"))]
11737    {
11738        out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11739        out.push_str("Generic disk load simulated.\n");
11740    }
11741
11742    Ok(out)
11743}
11744
11745fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11746    let mut out = String::from("Host inspection: permissions\n\n");
11747    out.push_str(&format!(
11748        "Auditing access control for: {}\n\n",
11749        path.display()
11750    ));
11751
11752    #[cfg(target_os = "windows")]
11753    {
11754        let script = format!(
11755            "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11756            path.display()
11757        );
11758        let output = Command::new("powershell")
11759            .args(["-NoProfile", "-Command", &script])
11760            .output()
11761            .map_err(|e| format!("ACL check failed: {e}"))?;
11762
11763        let text = String::from_utf8_lossy(&output.stdout);
11764        if text.trim().is_empty() {
11765            out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11766        } else {
11767            out.push_str("=== Windows NTFS Permissions ===\n");
11768            out.push_str(&text);
11769        }
11770    }
11771
11772    #[cfg(not(target_os = "windows"))]
11773    {
11774        let output = Command::new("ls")
11775            .args(["-ld", &path.to_string_lossy()])
11776            .output()
11777            .map_err(|e| format!("ls check failed: {e}"))?;
11778        out.push_str("=== Unix File Permissions ===\n");
11779        out.push_str(&String::from_utf8_lossy(&output.stdout));
11780    }
11781
11782    Ok(out.trim_end().to_string())
11783}
11784
11785fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11786    let mut out = String::from("Host inspection: login_history\n\n");
11787
11788    #[cfg(target_os = "windows")]
11789    {
11790        out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11791        out.push_str("Note: This typically requires Administrator elevation.\n\n");
11792
11793        let n = max_entries.clamp(1, 50);
11794        let script = format!(
11795            r#"try {{
11796    $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11797    $events | ForEach-Object {{
11798        $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11799        # Extract target user name from the XML/Properties if possible
11800        $user = $_.Properties[5].Value
11801        $type = $_.Properties[8].Value
11802        "[$time] User: $user | Type: $type"
11803    }}
11804}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11805        );
11806
11807        let output = Command::new("powershell")
11808            .args(["-NoProfile", "-Command", &script])
11809            .output()
11810            .map_err(|e| format!("Login history query failed: {e}"))?;
11811
11812        let text = String::from_utf8_lossy(&output.stdout);
11813        if text.starts_with("ERROR:") {
11814            out.push_str(&format!("Unable to query Security Log: {}\n", text));
11815        } else if text.trim().is_empty() {
11816            out.push_str("No recent logon events found or access denied.\n");
11817        } else {
11818            out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11819            out.push_str(&text);
11820        }
11821    }
11822
11823    #[cfg(not(target_os = "windows"))]
11824    {
11825        let output = Command::new("last")
11826            .args(["-n", &max_entries.to_string()])
11827            .output()
11828            .map_err(|e| format!("last command failed: {e}"))?;
11829        out.push_str("=== Unix Login History (last) ===\n");
11830        out.push_str(&String::from_utf8_lossy(&output.stdout));
11831    }
11832
11833    Ok(out.trim_end().to_string())
11834}
11835
11836fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11837    let mut out = String::from("Host inspection: share_access\n\n");
11838    out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11839
11840    #[cfg(target_os = "windows")]
11841    {
11842        let script = format!(
11843            r#"
11844$p = '{}'
11845$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11846if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11847    $res.Reachable = $true
11848    try {{
11849        $null = Get-ChildItem -Path $p -ErrorAction Stop
11850        $res.Readable = $true
11851    }} catch {{
11852        $res.Error = $_.Exception.Message
11853    }}
11854}} else {{
11855    $res.Error = "Server unreachable (Ping failed)"
11856}}
11857"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11858            path.display()
11859        );
11860
11861        let output = Command::new("powershell")
11862            .args(["-NoProfile", "-Command", &script])
11863            .output()
11864            .map_err(|e| format!("Share test failed: {e}"))?;
11865
11866        let text = String::from_utf8_lossy(&output.stdout);
11867        out.push_str("=== Share Triage Results ===\n");
11868        out.push_str(&text);
11869    }
11870
11871    #[cfg(not(target_os = "windows"))]
11872    {
11873        out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11874    }
11875
11876    Ok(out.trim_end().to_string())
11877}
11878
11879fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11880    let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11881    out.push_str(&format!("Issue: {}\n\n", issue));
11882    out.push_str("Proposed Remediation Steps:\n");
11883    out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11884    out.push_str("   `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11885    out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11886    out.push_str("   `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11887    out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11888    out.push_str("   `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11889    out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11890    out.push_str(
11891        "   `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11892    );
11893
11894    Ok(out)
11895}
11896
11897fn inspect_registry_audit() -> Result<String, String> {
11898    let mut out = String::from("Host inspection: registry_audit\n\n");
11899    out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11900
11901    #[cfg(target_os = "windows")]
11902    {
11903        let script = r#"
11904$findings = @()
11905
11906# 1. Image File Execution Options (Debugger Hijacking)
11907$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11908if (Test-Path $ifeo) {
11909    Get-ChildItem $ifeo | ForEach-Object {
11910        $p = Get-ItemProperty $_.PSPath
11911        if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11912    }
11913}
11914
11915# 2. Winlogon Shell Integrity
11916$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11917$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11918if ($shell -and $shell -ne "explorer.exe") {
11919    $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11920}
11921
11922# 3. Session Manager BootExecute
11923$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11924$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11925if ($boot -and $boot -notcontains "autocheck autochk *") {
11926    $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11927}
11928
11929if ($findings.Count -eq 0) {
11930    "PASS: No common registry hijacking or shell overrides detected."
11931} else {
11932    $findings -join "`n"
11933}
11934"#;
11935        let output = Command::new("powershell")
11936            .args(["-NoProfile", "-Command", &script])
11937            .output()
11938            .map_err(|e| format!("Registry audit failed: {e}"))?;
11939
11940        let text = String::from_utf8_lossy(&output.stdout);
11941        out.push_str("=== Persistence & Integrity Check ===\n");
11942        out.push_str(&text);
11943    }
11944
11945    #[cfg(not(target_os = "windows"))]
11946    {
11947        out.push_str("Registry auditing is specific to Windows environments.\n");
11948    }
11949
11950    Ok(out.trim_end().to_string())
11951}
11952
11953fn inspect_thermal() -> Result<String, String> {
11954    let mut out = String::from("Host inspection: thermal\n\n");
11955    out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11956
11957    #[cfg(target_os = "windows")]
11958    {
11959        let script = r#"
11960$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11961if ($thermal) {
11962    $thermal | ForEach-Object {
11963        $temp = [math]::Round(($_.Temperature - 273.15), 1)
11964        "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11965    }
11966} else {
11967    "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11968    $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11969    "Current CPU Load: $throttling%"
11970}
11971"#;
11972        let output = Command::new("powershell")
11973            .args(["-NoProfile", "-Command", script])
11974            .output()
11975            .map_err(|e| format!("Thermal check failed: {e}"))?;
11976        out.push_str("=== Windows Thermal State ===\n");
11977        out.push_str(&String::from_utf8_lossy(&output.stdout));
11978    }
11979
11980    #[cfg(not(target_os = "windows"))]
11981    {
11982        out.push_str(
11983            "Thermal inspection is currently optimized for Windows performance counters.\n",
11984        );
11985    }
11986
11987    Ok(out.trim_end().to_string())
11988}
11989
11990fn inspect_activation() -> Result<String, String> {
11991    let mut out = String::from("Host inspection: activation\n\n");
11992    out.push_str("Auditing Windows activation and license state...\n\n");
11993
11994    #[cfg(target_os = "windows")]
11995    {
11996        let script = r#"
11997$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11998$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
11999"Status: $($xpr.Trim())"
12000"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
12001"#;
12002        let output = Command::new("powershell")
12003            .args(["-NoProfile", "-Command", script])
12004            .output()
12005            .map_err(|e| format!("Activation check failed: {e}"))?;
12006        out.push_str("=== Windows License Report ===\n");
12007        out.push_str(&String::from_utf8_lossy(&output.stdout));
12008    }
12009
12010    #[cfg(not(target_os = "windows"))]
12011    {
12012        out.push_str("Windows activation check is specific to the Windows platform.\n");
12013    }
12014
12015    Ok(out.trim_end().to_string())
12016}
12017
12018fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
12019    let mut out = String::from("Host inspection: patch_history\n\n");
12020    out.push_str(&format!(
12021        "Listing the last {} installed Windows updates (KBs)...\n\n",
12022        max_entries
12023    ));
12024
12025    #[cfg(target_os = "windows")]
12026    {
12027        let n = max_entries.clamp(1, 50);
12028        let script = format!(
12029            "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
12030            n
12031        );
12032        let output = Command::new("powershell")
12033            .args(["-NoProfile", "-Command", &script])
12034            .output()
12035            .map_err(|e| format!("Patch history query failed: {e}"))?;
12036        out.push_str("=== Recent HotFixes (KBs) ===\n");
12037        out.push_str(&String::from_utf8_lossy(&output.stdout));
12038    }
12039
12040    #[cfg(not(target_os = "windows"))]
12041    {
12042        out.push_str("Patch history is currently focused on Windows HotFixes.\n");
12043    }
12044
12045    Ok(out.trim_end().to_string())
12046}
12047
12048// ── ad_user ──────────────────────────────────────────────────────────────────
12049
12050fn inspect_ad_user(identity: &str) -> Result<String, String> {
12051    let mut out = String::from("Host inspection: ad_user\n\n");
12052    let ident = identity.trim();
12053    if ident.is_empty() {
12054        out.push_str("Status: No identity specified. Performing self-discovery...\n");
12055        #[cfg(target_os = "windows")]
12056        {
12057            let script = r#"
12058$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
12059"USER: " + $u.Name
12060"SID: " + $u.User.Value
12061"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
12062"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
12063"#;
12064            let output = Command::new("powershell")
12065                .args(["-NoProfile", "-Command", script])
12066                .output()
12067                .ok();
12068            if let Some(o) = output {
12069                out.push_str(&String::from_utf8_lossy(&o.stdout));
12070            }
12071        }
12072        return Ok(out);
12073    }
12074
12075    #[cfg(target_os = "windows")]
12076    {
12077        let script = format!(
12078            r#"
12079try {{
12080    $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
12081    "NAME: " + $u.Name
12082    "SID: " + $u.SID
12083    "ENABLED: " + $u.Enabled
12084    "EXPIRED: " + $u.PasswordExpired
12085    "LOGON: " + $u.LastLogonDate
12086    "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
12087}} catch {{
12088    # Fallback to net user if AD module is missing or fails
12089    $net = net user "{ident}" /domain 2>&1
12090    if ($LASTEXITCODE -eq 0) {{
12091        $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
12092    }} else {{
12093        "ERROR: " + $_.Exception.Message
12094    }}
12095}}"#
12096        );
12097
12098        let output = Command::new("powershell")
12099            .args(["-NoProfile", "-Command", &script])
12100            .output()
12101            .ok();
12102
12103        if let Some(o) = output {
12104            let stdout = String::from_utf8_lossy(&o.stdout);
12105            if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
12106                out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
12107            }
12108            out.push_str(&stdout);
12109        }
12110    }
12111
12112    #[cfg(not(target_os = "windows"))]
12113    {
12114        let _ = ident;
12115        out.push_str("(AD User lookup only available on Windows nodes)\n");
12116    }
12117
12118    Ok(out.trim_end().to_string())
12119}
12120
12121// ── dns_lookup ───────────────────────────────────────────────────────────────
12122
12123fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
12124    let mut out = String::from("Host inspection: dns_lookup\n\n");
12125    let target = name.trim();
12126    if target.is_empty() {
12127        return Err("Missing required target name for dns_lookup.".to_string());
12128    }
12129
12130    #[cfg(target_os = "windows")]
12131    {
12132        let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
12133        let output = Command::new("powershell")
12134            .args(["-NoProfile", "-Command", &script])
12135            .output()
12136            .ok();
12137        if let Some(o) = output {
12138            let stdout = String::from_utf8_lossy(&o.stdout);
12139            if stdout.trim().is_empty() {
12140                out.push_str(&format!("No {record_type} records found for {target}.\n"));
12141            } else {
12142                out.push_str(&stdout);
12143            }
12144        }
12145    }
12146
12147    #[cfg(not(target_os = "windows"))]
12148    {
12149        let output = Command::new("dig")
12150            .args([target, record_type, "+short"])
12151            .output()
12152            .ok();
12153        if let Some(o) = output {
12154            out.push_str(&String::from_utf8_lossy(&o.stdout));
12155        }
12156    }
12157
12158    Ok(out.trim_end().to_string())
12159}
12160
12161// ── hyperv ───────────────────────────────────────────────────────────────────
12162
12163#[cfg(target_os = "windows")]
12164fn ps_exec(script: &str) -> String {
12165    Command::new("powershell")
12166        .args(["-NoProfile", "-NonInteractive", "-Command", script])
12167        .output()
12168        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
12169        .unwrap_or_default()
12170}
12171
12172fn inspect_mdm_enrollment() -> Result<String, String> {
12173    #[cfg(target_os = "windows")]
12174    {
12175        let mut out = String::from("Host inspection: mdm_enrollment\n\n");
12176
12177        // ── dsregcmd /status — primary enrollment signal ──────────────────────
12178        out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
12179        let ps_dsreg = r#"
12180$raw = dsregcmd /status 2>$null
12181$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
12182            'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
12183foreach ($line in $raw) {
12184    $t = $line.Trim()
12185    foreach ($f in $fields) {
12186        if ($t -like "$f :*") {
12187            $val = ($t -split ':',2)[1].Trim()
12188            "$f`: $val"
12189        }
12190    }
12191}
12192"#;
12193        match run_powershell(ps_dsreg) {
12194            Ok(o) if !o.trim().is_empty() => {
12195                for line in o.lines() {
12196                    let l = line.trim();
12197                    if !l.is_empty() {
12198                        out.push_str(&format!("- {l}\n"));
12199                    }
12200                }
12201            }
12202            Ok(_) => out.push_str(
12203                "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
12204            ),
12205            Err(e) => out.push_str(&format!("- dsregcmd error: {e}\n")),
12206        }
12207
12208        // ── Registry enrollment accounts ──────────────────────────────────────
12209        out.push_str("\n=== Enrollment accounts (registry) ===\n");
12210        let ps_enroll = r#"
12211$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
12212if (Test-Path $base) {
12213    $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
12214    if ($accounts) {
12215        foreach ($acct in $accounts) {
12216            $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
12217            $upn    = if ($p.UPN)                { $p.UPN }                else { '(none)' }
12218            $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
12219            $type   = switch ($p.EnrollmentType) {
12220                6  { 'MDM' }
12221                13 { 'MAM' }
12222                default { "Type=$($p.EnrollmentType)" }
12223            }
12224            $state  = switch ($p.EnrollmentState) {
12225                1  { 'Enrolled' }
12226                2  { 'InProgress' }
12227                6  { 'Unenrolled' }
12228                default { "State=$($p.EnrollmentState)" }
12229            }
12230            "Account: $upn | $type | $state | $server"
12231        }
12232    } else { "No enrollment accounts found under $base" }
12233} else { "Enrollment registry key not found — device is not MDM-enrolled" }
12234"#;
12235        match run_powershell(ps_enroll) {
12236            Ok(o) => {
12237                for line in o.lines() {
12238                    let l = line.trim();
12239                    if !l.is_empty() {
12240                        out.push_str(&format!("- {l}\n"));
12241                    }
12242                }
12243            }
12244            Err(e) => out.push_str(&format!("- Registry read error: {e}\n")),
12245        }
12246
12247        // ── MDM service health ────────────────────────────────────────────────
12248        out.push_str("\n=== MDM services ===\n");
12249        let ps_svc = r#"
12250$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
12251foreach ($n in $names) {
12252    $s = Get-Service -Name $n -ErrorAction SilentlyContinue
12253    if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
12254}
12255"#;
12256        match run_powershell(ps_svc) {
12257            Ok(o) if !o.trim().is_empty() => {
12258                for line in o.lines() {
12259                    let l = line.trim();
12260                    if !l.is_empty() {
12261                        out.push_str(&format!("- {l}\n"));
12262                    }
12263                }
12264            }
12265            Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
12266            Err(e) => out.push_str(&format!("- Service query error: {e}\n")),
12267        }
12268
12269        // ── Recent MDM / Intune events ────────────────────────────────────────
12270        out.push_str("\n=== Recent MDM events (last 24h) ===\n");
12271        let ps_evt = r#"
12272$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
12273          'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
12274$cutoff = (Get-Date).AddHours(-24)
12275$found = $false
12276foreach ($log in $logs) {
12277    $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
12278            Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
12279    foreach ($e in $evts) {
12280        $found = $true
12281        $ts = $e.TimeCreated.ToString('HH:mm')
12282        $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
12283        "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
12284    }
12285}
12286if (-not $found) { "No MDM warning/error events in the last 24 hours" }
12287"#;
12288        match run_powershell(ps_evt) {
12289            Ok(o) => {
12290                for line in o.lines() {
12291                    let l = line.trim();
12292                    if !l.is_empty() {
12293                        out.push_str(&format!("- {l}\n"));
12294                    }
12295                }
12296            }
12297            Err(e) => out.push_str(&format!("- Event log read error: {e}\n")),
12298        }
12299
12300        // ── Findings ──────────────────────────────────────────────────────────
12301        out.push_str("\n=== Findings ===\n");
12302        let body = out.clone();
12303        let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
12304        let intune_running = body.contains("IntuneManagementExtension: Running");
12305        let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
12306
12307        if !enrolled {
12308            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");
12309        } else {
12310            out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
12311            if !intune_running {
12312                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");
12313            }
12314        }
12315        if has_errors {
12316            out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
12317        }
12318        if !enrolled && !has_errors {
12319            out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
12320        }
12321
12322        Ok(out)
12323    }
12324
12325    #[cfg(not(target_os = "windows"))]
12326    {
12327        Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
12328    }
12329}
12330
12331fn inspect_hyperv() -> Result<String, String> {
12332    #[cfg(target_os = "windows")]
12333    {
12334        let mut findings: Vec<String> = Vec::new();
12335        let mut out = String::new();
12336
12337        // --- Hyper-V role / VMMS service state ---
12338        let ps_role = r#"
12339$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
12340$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
12341$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
12342$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
12343"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
12344    $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
12345    $(if ($feature) { $feature.State } else { "Unknown" }),
12346    $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
12347    $(if ($ram) { $ram } else { "0" })
12348"#;
12349        let role_out = ps_exec(ps_role);
12350        out.push_str("=== Hyper-V role state ===\n");
12351
12352        let mut vmms_running = false;
12353        let mut host_ram_bytes: u64 = 0;
12354
12355        if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
12356            let kv: std::collections::HashMap<&str, &str> = line
12357                .split('|')
12358                .filter_map(|p| {
12359                    let mut it = p.splitn(2, ':');
12360                    Some((it.next()?, it.next()?))
12361                })
12362                .collect();
12363            let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
12364            let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
12365            let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
12366            host_ram_bytes = kv
12367                .get("HostRAMBytes")
12368                .and_then(|v| v.parse().ok())
12369                .unwrap_or(0);
12370
12371            let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
12372            vmms_running = vmms_status.starts_with("Running");
12373
12374            out.push_str(&format!("- Host: {host_name}\n"));
12375            out.push_str(&format!(
12376                "- Hyper-V feature: {}\n",
12377                if hyperv_installed {
12378                    "Enabled"
12379                } else {
12380                    "Not installed"
12381                }
12382            ));
12383            out.push_str(&format!("- VMMS service: {vmms_status}\n"));
12384            if host_ram_bytes > 0 {
12385                out.push_str(&format!(
12386                    "- Host physical RAM: {} GB\n",
12387                    host_ram_bytes / 1_073_741_824
12388                ));
12389            }
12390
12391            if !hyperv_installed {
12392                findings.push(
12393                    "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
12394                );
12395            } else if !vmms_running {
12396                findings.push(
12397                    "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
12398                );
12399            }
12400        } else {
12401            out.push_str("- Could not determine Hyper-V role state\n");
12402            findings.push("Hyper-V does not appear to be installed on this machine.".into());
12403        }
12404
12405        // --- Virtual machines ---
12406        out.push_str("\n=== Virtual machines ===\n");
12407        if vmms_running {
12408            let ps_vms = r#"
12409Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
12410    $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
12411    "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
12412        $_.Name, $_.State, $_.CPUUsage, $ram_gb,
12413        $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
12414        $_.Status, $_.Generation
12415}
12416"#;
12417            let vms_out = ps_exec(ps_vms);
12418            let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
12419
12420            if vm_lines.is_empty() {
12421                out.push_str("- No virtual machines found on this host\n");
12422            } else {
12423                let mut total_ram_bytes: u64 = 0;
12424                let mut saved_vms: Vec<String> = Vec::new();
12425                for line in &vm_lines {
12426                    let kv: std::collections::HashMap<&str, &str> = line
12427                        .split('|')
12428                        .filter_map(|p| {
12429                            let mut it = p.splitn(2, ':');
12430                            Some((it.next()?, it.next()?))
12431                        })
12432                        .collect();
12433                    let name = kv.get("VM").copied().unwrap_or("Unknown");
12434                    let state = kv.get("State").copied().unwrap_or("Unknown");
12435                    let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
12436                    let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
12437                    let uptime = kv.get("Uptime").copied().unwrap_or("Off");
12438                    let status = kv.get("Status").copied().unwrap_or("");
12439                    let gen = kv.get("Generation").copied().unwrap_or("?");
12440
12441                    if let Ok(r) = ram.parse::<f64>() {
12442                        total_ram_bytes += (r * 1_073_741_824.0) as u64;
12443                    }
12444                    if state.eq_ignore_ascii_case("Saved") {
12445                        saved_vms.push(name.to_string());
12446                    }
12447
12448                    out.push_str(&format!(
12449                        "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}\n"
12450                    ));
12451                    if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
12452                        out.push_str(&format!("  Status: {status}\n"));
12453                    }
12454                }
12455
12456                out.push_str(&format!("\n- Total VMs: {}\n", vm_lines.len()));
12457                if total_ram_bytes > 0 && host_ram_bytes > 0 {
12458                    let pct = (total_ram_bytes * 100) / host_ram_bytes;
12459                    out.push_str(&format!(
12460                        "- Total VM RAM assigned: {} GB ({pct}% of host RAM)\n",
12461                        total_ram_bytes / 1_073_741_824
12462                    ));
12463                    if pct > 90 {
12464                        findings.push(format!(
12465                            "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
12466                        ));
12467                    }
12468                }
12469                if !saved_vms.is_empty() {
12470                    findings.push(format!(
12471                        "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
12472                        saved_vms.join(", ")
12473                    ));
12474                }
12475            }
12476        } else {
12477            out.push_str("- VMMS not running — cannot enumerate VMs\n");
12478        }
12479
12480        // --- VM network switches ---
12481        out.push_str("\n=== VM network switches ===\n");
12482        if vmms_running {
12483            let ps_switches = r#"
12484Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
12485    "Switch:{0}|Type:{1}|Adapter:{2}" -f `
12486        $_.Name, $_.SwitchType,
12487        $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
12488}
12489"#;
12490            let sw_out = ps_exec(ps_switches);
12491            let switch_lines: Vec<&str> = sw_out
12492                .lines()
12493                .filter(|l| l.starts_with("Switch:"))
12494                .collect();
12495
12496            if switch_lines.is_empty() {
12497                out.push_str("- No VM switches configured\n");
12498            } else {
12499                for line in &switch_lines {
12500                    let kv: std::collections::HashMap<&str, &str> = line
12501                        .split('|')
12502                        .filter_map(|p| {
12503                            let mut it = p.splitn(2, ':');
12504                            Some((it.next()?, it.next()?))
12505                        })
12506                        .collect();
12507                    let name = kv.get("Switch").copied().unwrap_or("Unknown");
12508                    let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
12509                    let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
12510                    out.push_str(&format!("- {name} | Type: {sw_type} | NIC: {adapter}\n"));
12511                }
12512            }
12513        } else {
12514            out.push_str("- VMMS not running — cannot enumerate switches\n");
12515        }
12516
12517        // --- VM checkpoints ---
12518        out.push_str("\n=== VM checkpoints ===\n");
12519        if vmms_running {
12520            let ps_checkpoints = r#"
12521$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
12522if ($all) {
12523    $all | ForEach-Object {
12524        "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
12525            $_.Name, $_.VMName,
12526            $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
12527            $_.SnapshotType
12528    }
12529} else {
12530    "NONE"
12531}
12532"#;
12533            let cp_out = ps_exec(ps_checkpoints);
12534            if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
12535                out.push_str("- No checkpoints found\n");
12536            } else {
12537                let cp_lines: Vec<&str> = cp_out
12538                    .lines()
12539                    .filter(|l| l.starts_with("Checkpoint:"))
12540                    .collect();
12541                let mut per_vm: std::collections::HashMap<&str, usize> =
12542                    std::collections::HashMap::new();
12543                for line in &cp_lines {
12544                    let kv: std::collections::HashMap<&str, &str> = line
12545                        .split('|')
12546                        .filter_map(|p| {
12547                            let mut it = p.splitn(2, ':');
12548                            Some((it.next()?, it.next()?))
12549                        })
12550                        .collect();
12551                    let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
12552                    let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
12553                    let created = kv.get("Created").copied().unwrap_or("");
12554                    let cp_type = kv.get("Type").copied().unwrap_or("");
12555                    out.push_str(&format!(
12556                        "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}\n"
12557                    ));
12558                    *per_vm.entry(vm_name).or_insert(0) += 1;
12559                }
12560                for (vm, count) in &per_vm {
12561                    if *count >= 3 {
12562                        findings.push(format!(
12563                            "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
12564                        ));
12565                    }
12566                }
12567            }
12568        } else {
12569            out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
12570        }
12571
12572        let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
12573        if findings.is_empty() {
12574            result.push_str("- No Hyper-V health issues detected.\n");
12575        } else {
12576            for f in &findings {
12577                result.push_str(&format!("- Finding: {f}\n"));
12578            }
12579        }
12580        result.push('\n');
12581        result.push_str(&out);
12582        return Ok(result.trim_end().to_string());
12583    }
12584
12585    #[cfg(not(target_os = "windows"))]
12586    Ok(
12587        "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
12588            .into(),
12589    )
12590}
12591
12592// ── ip_config ────────────────────────────────────────────────────────────────
12593
12594fn inspect_ip_config() -> Result<String, String> {
12595    let mut out = String::from("Host inspection: ip_config\n\n");
12596
12597    #[cfg(target_os = "windows")]
12598    {
12599        let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
12600            $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
12601            '\\n  Status: ' + $_.NetAdapter.Status + \
12602            '\\n  Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
12603            '\\n  DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
12604            '\\n  DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12605            '\\n  IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
12606            '\\n  DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
12607        }";
12608        let output = Command::new("powershell")
12609            .args(["-NoProfile", "-Command", script])
12610            .output()
12611            .ok();
12612        if let Some(o) = output {
12613            out.push_str(&String::from_utf8_lossy(&o.stdout));
12614        }
12615    }
12616
12617    #[cfg(not(target_os = "windows"))]
12618    {
12619        let output = Command::new("ip").args(["addr", "show"]).output().ok();
12620        if let Some(o) = output {
12621            out.push_str(&String::from_utf8_lossy(&o.stdout));
12622        }
12623    }
12624
12625    Ok(out.trim_end().to_string())
12626}
12627
12628// ── event_query ──────────────────────────────────────────────────────────────
12629
12630fn inspect_event_query(
12631    event_id: Option<u32>,
12632    log_name: Option<&str>,
12633    source: Option<&str>,
12634    hours: u32,
12635    level: Option<&str>,
12636    max_entries: usize,
12637) -> Result<String, String> {
12638    #[cfg(target_os = "windows")]
12639    {
12640        let mut findings: Vec<String> = Vec::new();
12641
12642        // Build the PowerShell filter hash
12643        let log = log_name.unwrap_or("*");
12644        let cap = max_entries.min(50);
12645
12646        // Level mapping: Error=2, Warning=3, Information=4
12647        let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
12648            Some("error") | Some("errors") => Some(2u8),
12649            Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
12650            Some("information") | Some("info") => Some(4u8),
12651            _ => None,
12652        };
12653
12654        // Build filter hashtable entries
12655        let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
12656        if log != "*" {
12657            filter_parts.push(format!("LogName = '{log}'"));
12658        }
12659        if let Some(id) = event_id {
12660            filter_parts.push(format!("Id = {id}"));
12661        }
12662        if let Some(src) = source {
12663            filter_parts.push(format!("ProviderName = '{src}'"));
12664        }
12665        if let Some(lvl) = level_filter {
12666            filter_parts.push(format!("Level = {lvl}"));
12667        }
12668
12669        let filter_ht = filter_parts.join("; ");
12670
12671        let ps = format!(
12672            r#"
12673$filter = @{{ {filter_ht} }}
12674try {{
12675    $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
12676        Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
12677            @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
12678    if ($events) {{
12679        $events | ForEach-Object {{
12680            "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
12681                $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
12682                $_.Id, $_.LevelDisplayName, $_.ProviderName,
12683                ($_.Msg -replace '\|','/')
12684        }}
12685    }} else {{
12686        "NONE"
12687    }}
12688}} catch {{
12689    "ERROR:$($_.Exception.Message)"
12690}}
12691"#
12692        );
12693
12694        let raw = ps_exec(&ps);
12695        let lines: Vec<&str> = raw.lines().collect();
12696
12697        // Build query description for header
12698        let mut query_desc = format!("last {hours}h");
12699        if let Some(id) = event_id {
12700            query_desc.push_str(&format!(", Event ID {id}"));
12701        }
12702        if let Some(src) = source {
12703            query_desc.push_str(&format!(", source '{src}'"));
12704        }
12705        if log != "*" {
12706            query_desc.push_str(&format!(", log '{log}'"));
12707        }
12708        if let Some(l) = level {
12709            query_desc.push_str(&format!(", level '{l}'"));
12710        }
12711
12712        let mut out = format!("=== Event query: {query_desc} ===\n");
12713
12714        if lines
12715            .iter()
12716            .any(|l| l.trim() == "NONE" || l.trim().is_empty())
12717        {
12718            out.push_str("- No matching events found.\n");
12719        } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
12720            let msg = err_line.trim_start_matches("ERROR:").trim();
12721            if is_event_query_no_results_message(msg) {
12722                out.push_str("- No matching events found.\n");
12723            } else {
12724                out.push_str(&format!("- Query error: {msg}\n"));
12725                findings.push(format!("Event query failed: {msg}"));
12726            }
12727        } else {
12728            let event_lines: Vec<&str> = lines
12729                .iter()
12730                .filter(|l| l.starts_with("TIME:"))
12731                .copied()
12732                .collect();
12733            if event_lines.is_empty() {
12734                out.push_str("- No matching events found.\n");
12735            } else {
12736                // Tally by level for findings
12737                let mut error_count = 0usize;
12738                let mut warning_count = 0usize;
12739
12740                for line in &event_lines {
12741                    let kv: std::collections::HashMap<&str, &str> = line
12742                        .split('|')
12743                        .filter_map(|p| {
12744                            let mut it = p.splitn(2, ':');
12745                            Some((it.next()?, it.next()?))
12746                        })
12747                        .collect();
12748                    let time = kv.get("TIME").copied().unwrap_or("?");
12749                    let id = kv.get("ID").copied().unwrap_or("?");
12750                    let lvl = kv.get("LEVEL").copied().unwrap_or("?");
12751                    let src = kv.get("SOURCE").copied().unwrap_or("?");
12752                    let msg = kv.get("MSG").copied().unwrap_or("").trim();
12753
12754                    // Truncate long messages
12755                    let msg_display = if msg.len() > 120 {
12756                        format!("{}…", &msg[..120])
12757                    } else {
12758                        msg.to_string()
12759                    };
12760
12761                    out.push_str(&format!(
12762                        "- [{time}] ID {id} | {lvl} | {src}\n  {msg_display}\n"
12763                    ));
12764
12765                    if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
12766                        error_count += 1;
12767                    } else if lvl.eq_ignore_ascii_case("warning") {
12768                        warning_count += 1;
12769                    }
12770                }
12771
12772                out.push_str(&format!(
12773                    "\n- Total shown: {} event(s)\n",
12774                    event_lines.len()
12775                ));
12776
12777                if error_count > 0 {
12778                    findings.push(format!(
12779                        "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
12780                    ));
12781                }
12782                if warning_count > 5 {
12783                    findings.push(format!(
12784                        "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
12785                    ));
12786                }
12787            }
12788        }
12789
12790        let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
12791        if findings.is_empty() {
12792            result.push_str("- No actionable findings from this event query.\n");
12793        } else {
12794            for f in &findings {
12795                result.push_str(&format!("- Finding: {f}\n"));
12796            }
12797        }
12798        result.push('\n');
12799        result.push_str(&out);
12800        return Ok(result.trim_end().to_string());
12801    }
12802
12803    #[cfg(not(target_os = "windows"))]
12804    {
12805        let _ = (event_id, log_name, source, hours, level, max_entries);
12806        Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
12807    }
12808}
12809
12810// ── app_crashes ───────────────────────────────────────────────────────────────
12811
12812fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
12813    let n = max_entries.clamp(5, 50);
12814    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12815    let mut findings: Vec<String> = Vec::new();
12816    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12817    let mut sections = String::new();
12818
12819    #[cfg(target_os = "windows")]
12820    {
12821        let proc_filter_ps = match process_filter {
12822            Some(proc) => format!(
12823                "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12824                proc.replace('\'', "''")
12825            ),
12826            None => String::new(),
12827        };
12828
12829        let ps = format!(
12830            r#"
12831$results = @()
12832try {{
12833    $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12834    if ($events) {{
12835        foreach ($e in $events) {{
12836            $msg  = $e.Message
12837            $app  = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12838            $ver  = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12839            $mod  = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12840            $exc  = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12841            $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12842            $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12843        }}
12844        $results
12845    }} else {{ 'NONE' }}
12846}} catch {{ 'ERROR:' + $_.Exception.Message }}
12847"#
12848        );
12849
12850        let raw = ps_exec(&ps);
12851        let text = raw.trim();
12852
12853        // WER archive count (non-blocking best-effort)
12854        let wer_ps = r#"
12855$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12856$count = 0
12857if (Test-Path $wer) {
12858    $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12859}
12860$count
12861"#;
12862        let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12863
12864        if text == "NONE" {
12865            sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12866        } else if text.starts_with("ERROR:") {
12867            let msg = text.trim_start_matches("ERROR:").trim();
12868            sections.push_str(&format!(
12869                "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12870            ));
12871        } else {
12872            let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12873            let crash_count = events
12874                .iter()
12875                .filter(|l| l.splitn(3, '|').nth(1) == Some("CRASH"))
12876                .count();
12877            let hang_count = events
12878                .iter()
12879                .filter(|l| l.splitn(3, '|').nth(1) == Some("HANG"))
12880                .count();
12881
12882            // Tally crashes per app
12883            let mut app_counts: std::collections::HashMap<String, usize> =
12884                std::collections::HashMap::new();
12885            for line in &events {
12886                let parts: Vec<&str> = line.splitn(6, '|').collect();
12887                if parts.len() >= 3 {
12888                    *app_counts.entry(parts[2].to_string()).or_insert(0) += 1;
12889                }
12890            }
12891
12892            if crash_count > 0 {
12893                findings.push(format!(
12894                    "{crash_count} application crash event(s) — review below for faulting app and exception code."
12895                ));
12896            }
12897            if hang_count > 0 {
12898                findings.push(format!(
12899                    "{hang_count} application hang event(s) — process stopped responding."
12900                ));
12901            }
12902            if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12903                if count > 1 {
12904                    findings.push(format!(
12905                        "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
12906                    ));
12907                }
12908            }
12909            if wer_count > 10 {
12910                findings.push(format!(
12911                    "{wer_count} WER reports archived — elevated crash history on this machine."
12912                ));
12913            }
12914
12915            let filter_note = match process_filter {
12916                Some(p) => format!(" (filtered: {p})"),
12917                None => String::new(),
12918            };
12919            sections.push_str(&format!(
12920                "=== Application crashes and hangs{filter_note} ===\n"
12921            ));
12922
12923            for line in &events {
12924                let parts: Vec<&str> = line.splitn(6, '|').collect();
12925                if parts.len() >= 6 {
12926                    let time = parts[0];
12927                    let kind = parts[1];
12928                    let app = parts[2];
12929                    let ver = parts[3];
12930                    let module = parts[4];
12931                    let exc = parts[5];
12932                    let ver_note = if !ver.is_empty() {
12933                        format!(" v{ver}")
12934                    } else {
12935                        String::new()
12936                    };
12937                    sections.push_str(&format!("  [{time}] {kind}: {app}{ver_note}\n"));
12938                    if !module.is_empty() && module != "?" {
12939                        let exc_note = if !exc.is_empty() {
12940                            format!(" (exc {exc})")
12941                        } else {
12942                            String::new()
12943                        };
12944                        sections.push_str(&format!("    faulting module: {module}{exc_note}\n"));
12945                    } else if !exc.is_empty() {
12946                        sections.push_str(&format!("    exception: {exc}\n"));
12947                    }
12948                }
12949            }
12950            sections.push_str(&format!(
12951                "\n  Total: {crash_count} crash(es), {hang_count} hang(s)\n"
12952            ));
12953
12954            if wer_count > 0 {
12955                sections.push_str(&format!(
12956                    "\n=== Windows Error Reporting ===\n  WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
12957                ));
12958            }
12959        }
12960    }
12961
12962    #[cfg(not(target_os = "windows"))]
12963    {
12964        let _ = (process_filter, n);
12965        sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
12966    }
12967
12968    let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
12969    if findings.is_empty() {
12970        result.push_str("- No actionable findings.\n");
12971    } else {
12972        for f in &findings {
12973            result.push_str(&format!("- Finding: {f}\n"));
12974        }
12975    }
12976    result.push('\n');
12977    result.push_str(&sections);
12978    Ok(result.trim_end().to_string())
12979}
12980
12981#[cfg(target_os = "windows")]
12982fn gpu_voltage_telemetry_note() -> String {
12983    let output = Command::new("nvidia-smi")
12984        .args(["--help-query-gpu"])
12985        .output();
12986
12987    match output {
12988        Ok(o) => {
12989            let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
12990            if text.contains("\"voltage\"") || text.contains("voltage.") {
12991                "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
12992            } else {
12993                "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()
12994            }
12995        }
12996        Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
12997    }
12998}
12999
13000#[cfg(target_os = "windows")]
13001fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
13002    if raw == 0 {
13003        return None;
13004    }
13005    if raw & 0x80 != 0 {
13006        let tenths = raw & 0x7f;
13007        return Some(format!(
13008            "{:.1} V (firmware-reported WMI current voltage)",
13009            tenths as f64 / 10.0
13010        ));
13011    }
13012
13013    let legacy = match raw {
13014        1 => Some("5.0 V"),
13015        2 => Some("3.3 V"),
13016        4 => Some("2.9 V"),
13017        _ => None,
13018    }?;
13019    Some(format!(
13020        "{} (legacy WMI voltage capability flag, not live telemetry)",
13021        legacy
13022    ))
13023}
13024
13025async fn inspect_overclocker() -> Result<String, String> {
13026    let mut out = String::from("Host inspection: overclocker\n\n");
13027
13028    #[cfg(target_os = "windows")]
13029    {
13030        out.push_str(
13031            "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
13032        );
13033
13034        // 1. NVIDIA Census
13035        let nvidia = Command::new("nvidia-smi")
13036            .args([
13037                "--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",
13038                "--format=csv,noheader,nounits",
13039            ])
13040            .output();
13041
13042        if let Ok(o) = nvidia {
13043            let stdout = String::from_utf8_lossy(&o.stdout);
13044            if !stdout.trim().is_empty() {
13045                out.push_str("=== GPU SENSE (NVIDIA) ===\n");
13046                let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
13047                if parts.len() >= 10 {
13048                    out.push_str(&format!("- Model:      {}\n", parts[0]));
13049                    out.push_str(&format!("- Graphics:   {} MHz\n", parts[1]));
13050                    out.push_str(&format!("- Memory:     {} MHz\n", parts[2]));
13051                    out.push_str(&format!("- Fan Speed:  {}%\n", parts[3]));
13052                    out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
13053                    if !parts[6].eq_ignore_ascii_case("[N/A]") {
13054                        out.push_str(&format!("- Power Avg:  {} W\n", parts[6]));
13055                    }
13056                    if !parts[7].eq_ignore_ascii_case("[N/A]") {
13057                        out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
13058                    }
13059                    if !parts[8].eq_ignore_ascii_case("[N/A]") {
13060                        out.push_str(&format!("- Power Cap:  {} W requested\n", parts[8]));
13061                    }
13062                    if !parts[9].eq_ignore_ascii_case("[N/A]") {
13063                        out.push_str(&format!("- Power Enf:  {} W enforced\n", parts[9]));
13064                    }
13065                    out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
13066
13067                    if parts.len() > 10 {
13068                        let throttle_hex = parts[10];
13069                        let reasons = decode_nvidia_throttle_reasons(throttle_hex);
13070                        if !reasons.is_empty() {
13071                            out.push_str(&format!("- Throttling:  YES [Reason: {}]\n", reasons));
13072                        } else {
13073                            out.push_str("- Throttling:  None (Performance State: Max)\n");
13074                        }
13075                    }
13076                }
13077                out.push_str("\n");
13078            }
13079        }
13080
13081        out.push_str("=== VOLTAGE TELEMETRY ===\n");
13082        out.push_str(&format!(
13083            "- GPU Voltage:  {}\n\n",
13084            gpu_voltage_telemetry_note()
13085        ));
13086
13087        // 1b. Session Trends (RAM-only historians)
13088        let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
13089        let history = gpu_state.history.lock().unwrap();
13090        if history.len() >= 2 {
13091            out.push_str("=== SILICON TRENDS (Session) ===\n");
13092            let first = history.front().unwrap();
13093            let last = history.back().unwrap();
13094
13095            let temp_diff = last.temperature as i32 - first.temperature as i32;
13096            let clock_diff = last.core_clock as i32 - first.core_clock as i32;
13097
13098            let temp_trend = if temp_diff > 1 {
13099                "Rising"
13100            } else if temp_diff < -1 {
13101                "Falling"
13102            } else {
13103                "Stable"
13104            };
13105            let clock_trend = if clock_diff > 10 {
13106                "Increasing"
13107            } else if clock_diff < -10 {
13108                "Decreasing"
13109            } else {
13110                "Stable"
13111            };
13112
13113            out.push_str(&format!(
13114                "- Temperature: {} ({}°C anomaly)\n",
13115                temp_trend, temp_diff
13116            ));
13117            out.push_str(&format!(
13118                "- Core Clock:  {} ({} MHz delta)\n",
13119                clock_trend, clock_diff
13120            ));
13121            out.push_str("\n");
13122        }
13123
13124        // 2. CPU Time-Series (2 samples)
13125        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))\" }";
13126        let cpu_stats = Command::new("powershell")
13127            .args(["-NoProfile", "-Command", ps_cmd])
13128            .output();
13129
13130        if let Ok(o) = cpu_stats {
13131            let stdout = String::from_utf8_lossy(&o.stdout);
13132            if !stdout.trim().is_empty() {
13133                out.push_str("=== SILICON CORE (CPU) ===\n");
13134                for line in stdout.lines() {
13135                    if let Some((path, val)) = line.split_once(':') {
13136                        if path.to_lowercase().contains("processor frequency") {
13137                            out.push_str(&format!("- Current Freq:  {} MHz (2s Avg)\n", val));
13138                        } else if path.to_lowercase().contains("% of maximum frequency") {
13139                            out.push_str(&format!("- Throttling:     {}% of Max Capacity\n", val));
13140                            let throttle_num = val.parse::<f64>().unwrap_or(100.0);
13141                            if throttle_num < 95.0 {
13142                                out.push_str(
13143                                    "  [WARNING] Active downclocking or power-saving detected.\n",
13144                                );
13145                            }
13146                        }
13147                    }
13148                }
13149            }
13150        }
13151
13152        // 2b. CPU Thermal Fallback
13153        let thermal = Command::new("powershell")
13154            .args([
13155                "-NoProfile",
13156                "-Command",
13157                "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
13158            ])
13159            .output();
13160        if let Ok(o) = thermal {
13161            let stdout = String::from_utf8_lossy(&o.stdout);
13162            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13163                let temp = if v.is_array() {
13164                    v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13165                } else {
13166                    v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13167                };
13168                if temp > 1.0 {
13169                    out.push_str(&format!("- CPU Package:   {}°C (ACPI Zone)\n", temp));
13170                }
13171            }
13172        }
13173
13174        // 3. WMI Static Fallback/Context
13175        let wmi = Command::new("powershell")
13176            .args([
13177                "-NoProfile",
13178                "-Command",
13179                "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
13180            ])
13181            .output();
13182
13183        if let Ok(o) = wmi {
13184            let stdout = String::from_utf8_lossy(&o.stdout);
13185            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13186                out.push_str("\n=== HARDWARE DNA ===\n");
13187                out.push_str(&format!(
13188                    "- Rated Max:     {} MHz\n",
13189                    v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
13190                ));
13191                match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
13192                    Some(raw) => {
13193                        if let Some(decoded) = decode_wmi_processor_voltage(raw) {
13194                            out.push_str(&format!("- CPU Voltage:   {}\n", decoded));
13195                        } else {
13196                            out.push_str(
13197                                "- CPU Voltage:   Unavailable or non-telemetry WMI value on this firmware path\n",
13198                            );
13199                        }
13200                    }
13201                    None => out.push_str("- CPU Voltage:   Unavailable on this WMI path\n"),
13202                }
13203            }
13204        }
13205    }
13206
13207    #[cfg(not(target_os = "windows"))]
13208    {
13209        out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
13210    }
13211
13212    Ok(out.trim_end().to_string())
13213}
13214
13215/// Decodes the NVIDIA Clocks Throttle Reasons HEX bitmask.
13216#[cfg(target_os = "windows")]
13217fn decode_nvidia_throttle_reasons(hex: &str) -> String {
13218    let hex = hex.trim().trim_start_matches("0x");
13219    let val = match u64::from_str_radix(hex, 16) {
13220        Ok(v) => v,
13221        Err(_) => return String::new(),
13222    };
13223
13224    if val == 0 {
13225        return String::new();
13226    }
13227
13228    let mut reasons = Vec::new();
13229    if val & 0x01 != 0 {
13230        reasons.push("GPU Idle");
13231    }
13232    if val & 0x02 != 0 {
13233        reasons.push("Applications Clocks Setting");
13234    }
13235    if val & 0x04 != 0 {
13236        reasons.push("SW Power Cap (PL1/PL2)");
13237    }
13238    if val & 0x08 != 0 {
13239        reasons.push("HW Slowdown (Thermal/Power)");
13240    }
13241    if val & 0x10 != 0 {
13242        reasons.push("Sync Boost");
13243    }
13244    if val & 0x20 != 0 {
13245        reasons.push("SW Thermal Slowdown");
13246    }
13247    if val & 0x40 != 0 {
13248        reasons.push("HW Thermal Slowdown");
13249    }
13250    if val & 0x80 != 0 {
13251        reasons.push("HW Power Brake Slowdown");
13252    }
13253    if val & 0x100 != 0 {
13254        reasons.push("Display Clock Setting");
13255    }
13256
13257    reasons.join(", ")
13258}
13259
13260// ── PowerShell helper (used by camera / sign_in / search_index) ───────────────
13261
13262#[cfg(windows)]
13263fn run_powershell(script: &str) -> Result<String, String> {
13264    use std::process::Command;
13265    let out = Command::new("powershell")
13266        .args(["-NoProfile", "-NonInteractive", "-Command", script])
13267        .output()
13268        .map_err(|e| format!("powershell launch failed: {e}"))?;
13269    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
13270}
13271
13272// ── inspect_camera ────────────────────────────────────────────────────────────
13273
13274#[cfg(windows)]
13275fn inspect_camera(max_entries: usize) -> Result<String, String> {
13276    let mut out = String::from("=== Camera devices ===\n");
13277
13278    // PnP camera devices
13279    let ps_devices = r#"
13280Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
13281ForEach-Object {
13282    $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
13283    "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
13284}
13285"#;
13286    match run_powershell(ps_devices) {
13287        Ok(o) if !o.trim().is_empty() => {
13288            for line in o.lines().take(max_entries) {
13289                let l = line.trim();
13290                if !l.is_empty() {
13291                    out.push_str(&format!("- {l}\n"));
13292                }
13293            }
13294        }
13295        _ => out.push_str("- No camera devices found via PnP\n"),
13296    }
13297
13298    // Windows privacy / capability gate
13299    out.push_str("\n=== Windows camera privacy ===\n");
13300    let ps_privacy = r#"
13301$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
13302$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
13303"Global: $global"
13304$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
13305    Where-Object { $_.PSChildName -ne 'NonPackaged' } |
13306    ForEach-Object {
13307        $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
13308        if ($v) { "  $($_.PSChildName): $v" }
13309    }
13310$apps
13311"#;
13312    match run_powershell(ps_privacy) {
13313        Ok(o) if !o.trim().is_empty() => {
13314            for line in o.lines().take(max_entries) {
13315                let l = line.trim_end();
13316                if !l.is_empty() {
13317                    out.push_str(&format!("{l}\n"));
13318                }
13319            }
13320        }
13321        _ => out.push_str("- Could not read camera privacy registry\n"),
13322    }
13323
13324    // Windows Hello camera (IR / face auth)
13325    out.push_str("\n=== Biometric / Hello camera ===\n");
13326    let ps_bio = r#"
13327Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
13328ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
13329"#;
13330    match run_powershell(ps_bio) {
13331        Ok(o) if !o.trim().is_empty() => {
13332            for line in o.lines().take(max_entries) {
13333                let l = line.trim();
13334                if !l.is_empty() {
13335                    out.push_str(&format!("- {l}\n"));
13336                }
13337            }
13338        }
13339        _ => out.push_str("- No biometric devices found\n"),
13340    }
13341
13342    // Findings
13343    let mut findings: Vec<String> = Vec::new();
13344    if out.contains("Status: Error") || out.contains("Status: Unknown") {
13345        findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
13346    }
13347    if out.contains("Global: Deny") {
13348        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());
13349    }
13350
13351    let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
13352    if findings.is_empty() {
13353        result.push_str("- No obvious camera or privacy gate issue detected.\n");
13354        result.push_str("  If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
13355    } else {
13356        for f in &findings {
13357            result.push_str(&format!("- Finding: {f}\n"));
13358        }
13359    }
13360    result.push('\n');
13361    result.push_str(&out);
13362    Ok(result)
13363}
13364
13365#[cfg(not(windows))]
13366fn inspect_camera(_max_entries: usize) -> Result<String, String> {
13367    Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
13368}
13369
13370// ── inspect_sign_in ───────────────────────────────────────────────────────────
13371
13372#[cfg(windows)]
13373fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
13374    let mut out = String::from("=== Windows Hello and sign-in state ===\n");
13375
13376    // Windows Hello PIN and face/fingerprint readiness
13377    let ps_hello = r#"
13378$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
13379$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
13380$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
13381"PIN-style logon path: $helloKey"
13382"WbioSrvc start type: $faceConfigured"
13383"FingerPrint key present: $pinConfigured"
13384"#;
13385    match run_powershell(ps_hello) {
13386        Ok(o) => {
13387            for line in o.lines().take(max_entries) {
13388                let l = line.trim();
13389                if !l.is_empty() {
13390                    out.push_str(&format!("- {l}\n"));
13391                }
13392            }
13393        }
13394        Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
13395    }
13396
13397    // Biometric service state
13398    out.push_str("\n=== Biometric service ===\n");
13399    let ps_bio_svc = r#"
13400$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
13401if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
13402else { "WbioSrvc not found" }
13403"#;
13404    match run_powershell(ps_bio_svc) {
13405        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
13406        Err(_) => out.push_str("- Could not query biometric service\n"),
13407    }
13408
13409    // Recent logon failure events
13410    out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
13411    let ps_events = r#"
13412$cutoff = (Get-Date).AddHours(-24)
13413Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
13414ForEach-Object {
13415    $xml = [xml]$_.ToXml()
13416    $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
13417    $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
13418    "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
13419} | Select-Object -First 10
13420"#;
13421    match run_powershell(ps_events) {
13422        Ok(o) if !o.trim().is_empty() => {
13423            let count = o.lines().filter(|l| !l.trim().is_empty()).count();
13424            out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
13425            for line in o.lines().take(max_entries) {
13426                let l = line.trim();
13427                if !l.is_empty() {
13428                    out.push_str(&format!("  {l}\n"));
13429                }
13430            }
13431        }
13432        _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
13433    }
13434
13435    // Credential providers
13436    out.push_str("\n=== Active credential providers ===\n");
13437    let ps_cp = r#"
13438Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
13439ForEach-Object {
13440    $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13441    if ($name) { $name }
13442} | Select-Object -First 15
13443"#;
13444    match run_powershell(ps_cp) {
13445        Ok(o) if !o.trim().is_empty() => {
13446            for line in o.lines().take(max_entries) {
13447                let l = line.trim();
13448                if !l.is_empty() {
13449                    out.push_str(&format!("- {l}\n"));
13450                }
13451            }
13452        }
13453        _ => out.push_str("- Could not enumerate credential providers\n"),
13454    }
13455
13456    let mut findings: Vec<String> = Vec::new();
13457    if out.contains("WbioSrvc | Status: Stopped") {
13458        findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
13459    }
13460    if out.contains("recent logon failure") && !out.contains("0 recent") {
13461        findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
13462    }
13463
13464    let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
13465    if findings.is_empty() {
13466        result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
13467        result.push_str("  If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
13468    } else {
13469        for f in &findings {
13470            result.push_str(&format!("- Finding: {f}\n"));
13471        }
13472    }
13473    result.push('\n');
13474    result.push_str(&out);
13475    Ok(result)
13476}
13477
13478#[cfg(not(windows))]
13479fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
13480    Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
13481}
13482
13483// ── inspect_installer_health ──────────────────────────────────────────────────
13484
13485#[cfg(windows)]
13486fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
13487    let mut out = String::from("=== Installer engines ===\n");
13488
13489    let ps_engines = r#"
13490$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
13491foreach ($name in $services) {
13492    $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
13493    if ($svc) {
13494        $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
13495        $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
13496        "$name | Status: $($svc.Status) | StartType: $startType"
13497    } else {
13498        "$name | Not present"
13499    }
13500}
13501if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
13502    "msiexec.exe | Present: Yes"
13503} else {
13504    "msiexec.exe | Present: No"
13505}
13506"#;
13507    match run_powershell(ps_engines) {
13508        Ok(o) if !o.trim().is_empty() => {
13509            for line in o.lines().take(max_entries + 6) {
13510                let l = line.trim();
13511                if !l.is_empty() {
13512                    out.push_str(&format!("- {l}\n"));
13513                }
13514            }
13515        }
13516        _ => out.push_str("- Could not inspect installer engine services\n"),
13517    }
13518
13519    out.push_str("\n=== winget and App Installer ===\n");
13520    let ps_winget = r#"
13521$cmd = Get-Command winget -ErrorAction SilentlyContinue
13522if ($cmd) {
13523    try {
13524        $v = & winget --version 2>$null
13525        if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
13526    } catch { "winget | Present but invocation failed" }
13527} else {
13528    "winget | Missing"
13529}
13530$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
13531if ($appInstaller) {
13532    "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
13533} else {
13534    "DesktopAppInstaller | Status: Missing"
13535}
13536"#;
13537    match run_powershell(ps_winget) {
13538        Ok(o) if !o.trim().is_empty() => {
13539            for line in o.lines().take(max_entries) {
13540                let l = line.trim();
13541                if !l.is_empty() {
13542                    out.push_str(&format!("- {l}\n"));
13543                }
13544            }
13545        }
13546        _ => out.push_str("- Could not inspect winget/App Installer state\n"),
13547    }
13548
13549    out.push_str("\n=== Microsoft Store packages ===\n");
13550    let ps_store = r#"
13551$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
13552if ($store) {
13553    "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
13554} else {
13555    "Microsoft.WindowsStore | Status: Missing"
13556}
13557"#;
13558    match run_powershell(ps_store) {
13559        Ok(o) if !o.trim().is_empty() => {
13560            for line in o.lines().take(max_entries) {
13561                let l = line.trim();
13562                if !l.is_empty() {
13563                    out.push_str(&format!("- {l}\n"));
13564                }
13565            }
13566        }
13567        _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
13568    }
13569
13570    out.push_str("\n=== Reboot and transaction blockers ===\n");
13571    let ps_blockers = r#"
13572$pending = $false
13573if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
13574    "RebootPending: CBS"
13575    $pending = $true
13576}
13577if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
13578    "RebootPending: WindowsUpdate"
13579    $pending = $true
13580}
13581$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
13582if ($rename) {
13583    "PendingFileRenameOperations: Yes"
13584    $pending = $true
13585}
13586if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
13587    "InstallerInProgress: Yes"
13588    $pending = $true
13589}
13590if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
13591"#;
13592    match run_powershell(ps_blockers) {
13593        Ok(o) if !o.trim().is_empty() => {
13594            for line in o.lines().take(max_entries) {
13595                let l = line.trim();
13596                if !l.is_empty() {
13597                    out.push_str(&format!("- {l}\n"));
13598                }
13599            }
13600        }
13601        _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
13602    }
13603
13604    out.push_str("\n=== Recent installer failures (7d) ===\n");
13605    let ps_failures = r#"
13606$cutoff = (Get-Date).AddDays(-7)
13607$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
13608    ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13609$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
13610    Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
13611    Select-Object -First 6 |
13612    ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
13613$all = @($msi) + @($appx)
13614if ($all.Count -eq 0) {
13615    "No recent MSI/AppX installer errors detected"
13616} else {
13617    $all | Select-Object -First 8
13618}
13619"#;
13620    match run_powershell(ps_failures) {
13621        Ok(o) if !o.trim().is_empty() => {
13622            for line in o.lines().take(max_entries + 2) {
13623                let l = line.trim();
13624                if !l.is_empty() {
13625                    out.push_str(&format!("- {l}\n"));
13626                }
13627            }
13628        }
13629        _ => out.push_str("- Could not inspect recent installer failure events\n"),
13630    }
13631
13632    let mut findings: Vec<String> = Vec::new();
13633    if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
13634        findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
13635    }
13636    if out.contains("msiexec.exe | Present: No") {
13637        findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
13638    }
13639    if out.contains("winget | Missing") {
13640        findings.push(
13641            "winget is missing - App Installer may not be installed or registered for this user."
13642                .into(),
13643        );
13644    }
13645    if out.contains("DesktopAppInstaller | Status: Missing") {
13646        findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
13647    }
13648    if out.contains("Microsoft.WindowsStore | Status: Missing") {
13649        findings.push(
13650            "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
13651                .into(),
13652        );
13653    }
13654    if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
13655        findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
13656    }
13657    if out.contains("InstallerInProgress: Yes") {
13658        findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
13659    }
13660    if out.contains("MSI | ") || out.contains("AppX | ") {
13661        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());
13662    }
13663
13664    let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
13665    if findings.is_empty() {
13666        result.push_str("- No obvious installer-platform blocker detected.\n");
13667    } else {
13668        for finding in &findings {
13669            result.push_str(&format!("- Finding: {finding}\n"));
13670        }
13671    }
13672    result.push('\n');
13673    result.push_str(&out);
13674    Ok(result)
13675}
13676
13677#[cfg(not(windows))]
13678fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
13679    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())
13680}
13681
13682// ── inspect_search_index ──────────────────────────────────────────────────────
13683
13684#[cfg(windows)]
13685fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
13686    let mut out = String::from("=== OneDrive client ===\n");
13687
13688    let ps_client = r#"
13689$candidatePaths = @(
13690    (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
13691    (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
13692    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
13693) | Where-Object { $_ -and (Test-Path $_) }
13694$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
13695$exe = $candidatePaths | Select-Object -First 1
13696if (-not $exe -and $proc) {
13697    try { $exe = $proc.Path } catch {}
13698}
13699if ($exe) {
13700    "Installed: Yes"
13701    "Executable: $exe"
13702    try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
13703} else {
13704    "Installed: Unknown"
13705}
13706if ($proc) {
13707    "Process: Running | PID: $($proc.Id)"
13708} else {
13709    "Process: Not running"
13710}
13711"#;
13712    match run_powershell(ps_client) {
13713        Ok(o) if !o.trim().is_empty() => {
13714            for line in o.lines().take(max_entries) {
13715                let l = line.trim();
13716                if !l.is_empty() {
13717                    out.push_str(&format!("- {l}\n"));
13718                }
13719            }
13720        }
13721        _ => out.push_str("- Could not inspect OneDrive client state\n"),
13722    }
13723
13724    out.push_str("\n=== OneDrive accounts ===\n");
13725    let ps_accounts = r#"
13726function MaskEmail([string]$Email) {
13727    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
13728    $parts = $Email.Split('@', 2)
13729    $local = $parts[0]
13730    $domain = $parts[1]
13731    if ($local.Length -le 1) { return "*@$domain" }
13732    return ($local.Substring(0,1) + "***@" + $domain)
13733}
13734$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13735if (Test-Path $base) {
13736    Get-ChildItem $base -ErrorAction SilentlyContinue |
13737        Sort-Object PSChildName |
13738        Select-Object -First 12 |
13739        ForEach-Object {
13740            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13741            $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
13742            $mail = MaskEmail ([string]$p.UserEmail)
13743            $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
13744            $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
13745            "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
13746        }
13747} else {
13748    "No OneDrive accounts configured"
13749}
13750"#;
13751    match run_powershell(ps_accounts) {
13752        Ok(o) if !o.trim().is_empty() => {
13753            for line in o.lines().take(max_entries) {
13754                let l = line.trim();
13755                if !l.is_empty() {
13756                    out.push_str(&format!("- {l}\n"));
13757                }
13758            }
13759        }
13760        _ => out.push_str("- Could not read OneDrive account registry state\n"),
13761    }
13762
13763    out.push_str("\n=== OneDrive policy overrides ===\n");
13764    let ps_policy = r#"
13765$paths = @(
13766    'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
13767    'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
13768)
13769$names = @(
13770    'DisableFileSyncNGSC',
13771    'DisableLibrariesDefaultSaveToOneDrive',
13772    'KFMSilentOptIn',
13773    'KFMBlockOptIn',
13774    'SilentAccountConfig'
13775)
13776$found = $false
13777foreach ($path in $paths) {
13778    if (Test-Path $path) {
13779        $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
13780        foreach ($name in $names) {
13781            $value = $p.$name
13782            if ($null -ne $value -and [string]$value -ne '') {
13783                "$path | $name=$value"
13784                $found = $true
13785            }
13786        }
13787    }
13788}
13789if (-not $found) { "No OneDrive policy overrides detected" }
13790"#;
13791    match run_powershell(ps_policy) {
13792        Ok(o) if !o.trim().is_empty() => {
13793            for line in o.lines().take(max_entries) {
13794                let l = line.trim();
13795                if !l.is_empty() {
13796                    out.push_str(&format!("- {l}\n"));
13797                }
13798            }
13799        }
13800        _ => out.push_str("- Could not read OneDrive policy state\n"),
13801    }
13802
13803    out.push_str("\n=== Known Folder Backup ===\n");
13804    let ps_kfm = r#"
13805$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13806$roots = @()
13807if (Test-Path $base) {
13808    Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
13809        $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13810        if ($p.UserFolder) {
13811            $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
13812        }
13813    }
13814}
13815$roots = $roots | Select-Object -Unique
13816$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
13817if (Test-Path $shell) {
13818    $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13819    $folders = @(
13820        @{ Name='Desktop'; Value=$props.Desktop },
13821        @{ Name='Documents'; Value=$props.Personal },
13822        @{ Name='Pictures'; Value=$props.'My Pictures' }
13823    )
13824    foreach ($folder in $folders) {
13825        $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13826        if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13827        $protected = $false
13828        foreach ($root in $roots) {
13829            if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13830                $protected = $true
13831                break
13832            }
13833        }
13834        "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13835    }
13836} else {
13837    "Explorer shell folders unavailable"
13838}
13839"#;
13840    match run_powershell(ps_kfm) {
13841        Ok(o) if !o.trim().is_empty() => {
13842            for line in o.lines().take(max_entries) {
13843                let l = line.trim();
13844                if !l.is_empty() {
13845                    out.push_str(&format!("- {l}\n"));
13846                }
13847            }
13848        }
13849        _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13850    }
13851
13852    let mut findings: Vec<String> = Vec::new();
13853    if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13854        findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13855    }
13856    if out.contains("No OneDrive accounts configured") {
13857        findings.push(
13858            "No OneDrive accounts are configured - sync cannot start until the user signs in."
13859                .into(),
13860        );
13861    }
13862    if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13863        findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13864    }
13865    if out.contains("Exists: No") {
13866        findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13867    }
13868    if out.contains("DisableFileSyncNGSC=1") {
13869        findings
13870            .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13871    }
13872    if out.contains("KFMBlockOptIn=1") {
13873        findings
13874            .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13875    }
13876    if out.contains("SyncRoot: C:\\") {
13877        let mut missing_kfm: Vec<&str> = Vec::new();
13878        for folder in ["Desktop", "Documents", "Pictures"] {
13879            if out.lines().any(|line| {
13880                line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13881            }) {
13882                missing_kfm.push(folder);
13883            }
13884        }
13885        if !missing_kfm.is_empty() {
13886            findings.push(format!(
13887                "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13888                missing_kfm.join(", ")
13889            ));
13890        }
13891    }
13892
13893    let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13894    if findings.is_empty() {
13895        result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13896    } else {
13897        for finding in &findings {
13898            result.push_str(&format!("- Finding: {finding}\n"));
13899        }
13900    }
13901    result.push('\n');
13902    result.push_str(&out);
13903    Ok(result)
13904}
13905
13906#[cfg(not(windows))]
13907fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
13908    Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
13909}
13910
13911#[cfg(windows)]
13912fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
13913    let mut out = String::from("=== Browser inventory ===\n");
13914
13915    let ps_inventory = r#"
13916$browsers = @(
13917    @{ Name='Edge'; Paths=@(
13918        (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
13919        (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
13920    ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
13921    @{ Name='Chrome'; Paths=@(
13922        (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
13923        (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
13924        (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
13925    ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
13926    @{ Name='Firefox'; Paths=@(
13927        (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
13928        (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
13929    ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
13930)
13931foreach ($browser in $browsers) {
13932    $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13933    if ($exe) {
13934        $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13935        $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
13936        "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
13937    } else {
13938        "$($browser.Name) | Installed: No"
13939    }
13940}
13941$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13942$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13943$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13944"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
13945"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
13946"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
13947"#;
13948    match run_powershell(ps_inventory) {
13949        Ok(o) if !o.trim().is_empty() => {
13950            for line in o.lines().take(max_entries + 6) {
13951                let l = line.trim();
13952                if !l.is_empty() {
13953                    out.push_str(&format!("- {l}\n"));
13954                }
13955            }
13956        }
13957        _ => out.push_str("- Could not inspect installed browser inventory\n"),
13958    }
13959
13960    out.push_str("\n=== Runtime state ===\n");
13961    let ps_runtime = r#"
13962$targets = 'msedge','chrome','firefox','msedgewebview2'
13963foreach ($name in $targets) {
13964    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13965    if ($procs) {
13966        $count = @($procs).Count
13967        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13968        "$name | Processes: $count | WorkingSetMB: $wsMb"
13969    } else {
13970        "$name | Processes: 0 | WorkingSetMB: 0"
13971    }
13972}
13973"#;
13974    match run_powershell(ps_runtime) {
13975        Ok(o) if !o.trim().is_empty() => {
13976            for line in o.lines().take(max_entries + 4) {
13977                let l = line.trim();
13978                if !l.is_empty() {
13979                    out.push_str(&format!("- {l}\n"));
13980                }
13981            }
13982        }
13983        _ => out.push_str("- Could not inspect browser runtime state\n"),
13984    }
13985
13986    out.push_str("\n=== WebView2 runtime ===\n");
13987    let ps_webview = r#"
13988$paths = @(
13989    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13990    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13991) | Where-Object { $_ -and (Test-Path $_) }
13992$runtimeDir = $paths | ForEach-Object {
13993    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13994        Where-Object { $_.Name -match '^\d+\.' } |
13995        Sort-Object Name -Descending |
13996        Select-Object -First 1
13997} | Select-Object -First 1
13998if ($runtimeDir) {
13999    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14000    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14001    "Installed: Yes"
14002    "Version: $version"
14003    "Executable: $exe"
14004} else {
14005    "Installed: No"
14006}
14007$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
14008"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
14009"#;
14010    match run_powershell(ps_webview) {
14011        Ok(o) if !o.trim().is_empty() => {
14012            for line in o.lines().take(max_entries) {
14013                let l = line.trim();
14014                if !l.is_empty() {
14015                    out.push_str(&format!("- {l}\n"));
14016                }
14017            }
14018        }
14019        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14020    }
14021
14022    out.push_str("\n=== Policy and proxy surface ===\n");
14023    let ps_policy = r#"
14024$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
14025$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
14026$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
14027$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
14028$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
14029"UserProxyEnabled: $proxyEnabled"
14030"UserProxyServer: $proxyServer"
14031"UserAutoConfigURL: $autoConfig"
14032"UserAutoDetect: $autoDetect"
14033$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
14034if ($winhttp) {
14035    $normalized = ($winhttp -replace '\s+', ' ').Trim()
14036    $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
14037    "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
14038    "WinHTTP: $normalized"
14039}
14040$policyTargets = @(
14041    @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
14042    @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
14043)
14044foreach ($policy in $policyTargets) {
14045    if (Test-Path $policy.Path) {
14046        $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
14047        foreach ($key in $policy.Keys) {
14048            $value = $item.$key
14049            if ($null -ne $value -and [string]$value -ne '') {
14050                if ($value -is [array]) {
14051                    "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
14052                } else {
14053                    "$($policy.Name)Policy | $key=$value"
14054                }
14055            }
14056        }
14057    }
14058}
14059"#;
14060    match run_powershell(ps_policy) {
14061        Ok(o) if !o.trim().is_empty() => {
14062            for line in o.lines().take(max_entries + 8) {
14063                let l = line.trim();
14064                if !l.is_empty() {
14065                    out.push_str(&format!("- {l}\n"));
14066                }
14067            }
14068        }
14069        _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
14070    }
14071
14072    out.push_str("\n=== Profile and cache pressure ===\n");
14073    let ps_profiles = r#"
14074$profiles = @(
14075    @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
14076    @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
14077    @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
14078)
14079foreach ($profile in $profiles) {
14080    if (Test-Path $profile.Root) {
14081        if ($profile.Name -eq 'Firefox') {
14082            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
14083        } else {
14084            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
14085                Where-Object {
14086                    $_.Name -eq 'Default' -or
14087                    $_.Name -eq 'Guest Profile' -or
14088                    $_.Name -eq 'System Profile' -or
14089                    $_.Name -like 'Profile *'
14090                }
14091        }
14092        $profileCount = @($dirs).Count
14093        $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
14094        if (-not $sizeBytes) { $sizeBytes = 0 }
14095        $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
14096        $extCount = 'Unknown'
14097        if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
14098            $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
14099        }
14100        "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
14101    } else {
14102        "$($profile.Name) | ProfileRoot: Missing"
14103    }
14104}
14105"#;
14106    match run_powershell(ps_profiles) {
14107        Ok(o) if !o.trim().is_empty() => {
14108            for line in o.lines().take(max_entries + 4) {
14109                let l = line.trim();
14110                if !l.is_empty() {
14111                    out.push_str(&format!("- {l}\n"));
14112                }
14113            }
14114        }
14115        _ => out.push_str("- Could not inspect browser profile pressure\n"),
14116    }
14117
14118    out.push_str("\n=== Recent browser failures (7d) ===\n");
14119    let ps_failures = r#"
14120$cutoff = (Get-Date).AddDays(-7)
14121$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
14122$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
14123    Where-Object {
14124        $msg = [string]$_.Message
14125        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14126        ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
14127    } |
14128    Select-Object -First 6
14129if ($events) {
14130    foreach ($event in $events) {
14131        $msg = ($event.Message -replace '\s+', ' ')
14132        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14133        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14134    }
14135} else {
14136    "No recent browser crash or WER events detected"
14137}
14138"#;
14139    match run_powershell(ps_failures) {
14140        Ok(o) if !o.trim().is_empty() => {
14141            for line in o.lines().take(max_entries + 2) {
14142                let l = line.trim();
14143                if !l.is_empty() {
14144                    out.push_str(&format!("- {l}\n"));
14145                }
14146            }
14147        }
14148        _ => out.push_str("- Could not inspect recent browser failure events\n"),
14149    }
14150
14151    let mut findings: Vec<String> = Vec::new();
14152    if out.contains("Edge | Installed: No")
14153        && out.contains("Chrome | Installed: No")
14154        && out.contains("Firefox | Installed: No")
14155    {
14156        findings.push(
14157            "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
14158                .into(),
14159        );
14160    }
14161    if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
14162        findings.push(
14163            "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
14164                .into(),
14165        );
14166    }
14167    if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
14168        findings.push(
14169            "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
14170                .into(),
14171        );
14172    }
14173    if out.contains("EdgePolicy | Proxy")
14174        || out.contains("ChromePolicy | Proxy")
14175        || out.contains("ExtensionInstallForcelist=")
14176    {
14177        findings.push(
14178            "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
14179                .into(),
14180        );
14181    }
14182    for browser in ["msedge", "chrome", "firefox"] {
14183        let process_marker = format!("{browser} | Processes: ");
14184        if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
14185            let count = line
14186                .split("| Processes: ")
14187                .nth(1)
14188                .and_then(|rest| rest.split(" |").next())
14189                .and_then(|value| value.trim().parse::<usize>().ok())
14190                .unwrap_or(0);
14191            let ws_mb = line
14192                .split("| WorkingSetMB: ")
14193                .nth(1)
14194                .and_then(|value| value.trim().parse::<f64>().ok())
14195                .unwrap_or(0.0);
14196            if count >= 25 {
14197                findings.push(format!(
14198                    "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
14199                ));
14200            } else if ws_mb >= 2500.0 {
14201                findings.push(format!(
14202                    "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
14203                ));
14204            }
14205        }
14206    }
14207    if out.contains("=== WebView2 runtime ===\n- Installed: No")
14208        || (out.contains("=== WebView2 runtime ===")
14209            && out.contains("- Installed: No")
14210            && out.contains("- ProcessCount: 0"))
14211    {
14212        findings.push(
14213            "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
14214                .into(),
14215        );
14216    }
14217    for browser in ["Edge", "Chrome", "Firefox"] {
14218        let prefix = format!("{browser} | ProfileRoot:");
14219        if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
14220            let size_gb = line
14221                .split("| SizeGB: ")
14222                .nth(1)
14223                .and_then(|rest| rest.split(" |").next())
14224                .and_then(|value| value.trim().parse::<f64>().ok())
14225                .unwrap_or(0.0);
14226            let ext_count = line
14227                .split("| Extensions: ")
14228                .nth(1)
14229                .and_then(|value| value.trim().parse::<usize>().ok())
14230                .unwrap_or(0);
14231            if size_gb >= 2.5 {
14232                findings.push(format!(
14233                    "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
14234                ));
14235            }
14236            if ext_count >= 20 {
14237                findings.push(format!(
14238                    "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
14239                ));
14240            }
14241        }
14242    }
14243    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14244        findings.push(
14245            "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
14246                .into(),
14247        );
14248    }
14249
14250    let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
14251    if findings.is_empty() {
14252        result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
14253    } else {
14254        for finding in &findings {
14255            result.push_str(&format!("- Finding: {finding}\n"));
14256        }
14257    }
14258    result.push('\n');
14259    result.push_str(&out);
14260    Ok(result)
14261}
14262
14263#[cfg(not(windows))]
14264fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
14265    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())
14266}
14267
14268#[cfg(windows)]
14269fn inspect_outlook(max_entries: usize) -> Result<String, String> {
14270    let mut out = String::from("=== Outlook install inventory ===\n");
14271
14272    let ps_install = r#"
14273$installPaths = @(
14274    (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14275    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14276    (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
14277    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
14278    (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
14279    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
14280)
14281$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14282if ($exe) {
14283    $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14284    $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
14285    "Installed: Yes"
14286    "Executable: $exe"
14287    "Version: $version"
14288    "Product: $productName"
14289} else {
14290    "Installed: No"
14291}
14292$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
14293if ($newOutlook) {
14294    "NewOutlook: Installed | Version: $($newOutlook.Version)"
14295} else {
14296    "NewOutlook: Not installed"
14297}
14298"#;
14299    match run_powershell(ps_install) {
14300        Ok(o) if !o.trim().is_empty() => {
14301            for line in o.lines().take(max_entries + 4) {
14302                let l = line.trim();
14303                if !l.is_empty() {
14304                    out.push_str(&format!("- {l}\n"));
14305                }
14306            }
14307        }
14308        _ => out.push_str("- Could not inspect Outlook install paths\n"),
14309    }
14310
14311    out.push_str("\n=== Runtime state ===\n");
14312    let ps_runtime = r#"
14313$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
14314if ($proc) {
14315    $count = @($proc).Count
14316    $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14317    $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
14318    "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
14319} else {
14320    "Running: No"
14321}
14322"#;
14323    match run_powershell(ps_runtime) {
14324        Ok(o) if !o.trim().is_empty() => {
14325            for line in o.lines().take(4) {
14326                let l = line.trim();
14327                if !l.is_empty() {
14328                    out.push_str(&format!("- {l}\n"));
14329                }
14330            }
14331        }
14332        _ => out.push_str("- Could not inspect Outlook runtime state\n"),
14333    }
14334
14335    out.push_str("\n=== Mail profiles ===\n");
14336    let ps_profiles = r#"
14337$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
14338if (-not (Test-Path $profileKey)) {
14339    $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
14340}
14341if (Test-Path $profileKey) {
14342    $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
14343    $count = @($profiles).Count
14344    "ProfileCount: $count"
14345    foreach ($p in $profiles | Select-Object -First 10) {
14346        "Profile: $($p.PSChildName)"
14347    }
14348} else {
14349    "ProfileCount: 0"
14350    "No Outlook profiles found in registry"
14351}
14352"#;
14353    match run_powershell(ps_profiles) {
14354        Ok(o) if !o.trim().is_empty() => {
14355            for line in o.lines().take(max_entries + 2) {
14356                let l = line.trim();
14357                if !l.is_empty() {
14358                    out.push_str(&format!("- {l}\n"));
14359                }
14360            }
14361        }
14362        _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
14363    }
14364
14365    out.push_str("\n=== OST and PST data files ===\n");
14366    let ps_datafiles = r#"
14367$searchRoots = @(
14368    (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
14369    (Join-Path $env:USERPROFILE 'Documents'),
14370    (Join-Path $env:USERPROFILE 'OneDrive\Documents')
14371) | Where-Object { $_ -and (Test-Path $_) }
14372$files = foreach ($root in $searchRoots) {
14373    Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
14374        Select-Object FullName,
14375            @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
14376            @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
14377            LastWriteTime
14378}
14379if ($files) {
14380    foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
14381        "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
14382    }
14383} else {
14384    "No OST or PST files found in standard locations"
14385}
14386"#;
14387    match run_powershell(ps_datafiles) {
14388        Ok(o) if !o.trim().is_empty() => {
14389            for line in o.lines().take(max_entries + 4) {
14390                let l = line.trim();
14391                if !l.is_empty() {
14392                    out.push_str(&format!("- {l}\n"));
14393                }
14394            }
14395        }
14396        _ => out.push_str("- Could not inspect OST/PST data files\n"),
14397    }
14398
14399    out.push_str("\n=== Add-in pressure ===\n");
14400    let ps_addins = r#"
14401$addinPaths = @(
14402    'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14403    'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14404    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
14405)
14406$addins = foreach ($path in $addinPaths) {
14407    if (Test-Path $path) {
14408        Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
14409            $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14410            $loadBehavior = $item.LoadBehavior
14411            $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
14412            [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
14413        }
14414    }
14415}
14416$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
14417$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
14418"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
14419foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
14420    $state = switch ($a.LoadBehavior) {
14421        0 { 'Disabled' }
14422        2 { 'LoadOnStart(inactive)' }
14423        3 { 'ActiveOnStart' }
14424        8 { 'DemandLoad' }
14425        9 { 'ActiveDemand' }
14426        16 { 'ConnectedFirst' }
14427        default { "LoadBehavior=$($a.LoadBehavior)" }
14428    }
14429    "$($a.Name) | $state"
14430}
14431$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
14432$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
14433if (Test-Path $disabledByResiliency) {
14434    $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
14435    $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
14436    if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
14437}
14438"#;
14439    match run_powershell(ps_addins) {
14440        Ok(o) if !o.trim().is_empty() => {
14441            for line in o.lines().take(max_entries + 8) {
14442                let l = line.trim();
14443                if !l.is_empty() {
14444                    out.push_str(&format!("- {l}\n"));
14445                }
14446            }
14447        }
14448        _ => out.push_str("- Could not inspect Outlook add-ins\n"),
14449    }
14450
14451    out.push_str("\n=== Authentication and cache friction ===\n");
14452    let ps_auth = r#"
14453$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14454$tokenCount = if (Test-Path $tokenCache) {
14455    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14456} else { 0 }
14457"TokenBrokerCacheFiles: $tokenCount"
14458$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
14459$credsCount = @($credentialManager).Count
14460"OfficeCredentialsInVault: $credsCount"
14461$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14462if (Test-Path $samlKey) {
14463    $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
14464    $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
14465    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14466    "WAMOverride: $connected"
14467    "SignedInUserId: $signedIn"
14468}
14469$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
14470if (Test-Path $outlookReg) {
14471    $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
14472    if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
14473}
14474"#;
14475    match run_powershell(ps_auth) {
14476        Ok(o) if !o.trim().is_empty() => {
14477            for line in o.lines().take(max_entries + 4) {
14478                let l = line.trim();
14479                if !l.is_empty() {
14480                    out.push_str(&format!("- {l}\n"));
14481                }
14482            }
14483        }
14484        _ => out.push_str("- Could not inspect Outlook auth state\n"),
14485    }
14486
14487    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14488    let ps_events = r#"
14489$cutoff = (Get-Date).AddDays(-7)
14490$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14491    Where-Object {
14492        $msg = [string]$_.Message
14493        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
14494        ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
14495    } |
14496    Select-Object -First 8
14497if ($events) {
14498    foreach ($event in $events) {
14499        $msg = ($event.Message -replace '\s+', ' ')
14500        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14501        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14502    }
14503} else {
14504    "No recent Outlook crash or error events detected in Application log"
14505}
14506"#;
14507    match run_powershell(ps_events) {
14508        Ok(o) if !o.trim().is_empty() => {
14509            for line in o.lines().take(max_entries + 4) {
14510                let l = line.trim();
14511                if !l.is_empty() {
14512                    out.push_str(&format!("- {l}\n"));
14513                }
14514            }
14515        }
14516        _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
14517    }
14518
14519    let mut findings: Vec<String> = Vec::new();
14520
14521    if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
14522        findings.push(
14523            "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
14524                .into(),
14525        );
14526    }
14527
14528    if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
14529        let ws_mb = line
14530            .split("WorkingSetMB: ")
14531            .nth(1)
14532            .and_then(|r| r.split(" |").next())
14533            .and_then(|v| v.trim().parse::<f64>().ok())
14534            .unwrap_or(0.0);
14535        if ws_mb >= 1500.0 {
14536            findings.push(format!(
14537                "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
14538            ));
14539        }
14540    }
14541
14542    let large_ost: Vec<String> = out
14543        .lines()
14544        .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
14545        .filter_map(|l| {
14546            let mb = l
14547                .split("SizeMB: ")
14548                .nth(1)
14549                .and_then(|r| r.split(" |").next())
14550                .and_then(|v| v.trim().parse::<f64>().ok())
14551                .unwrap_or(0.0);
14552            if mb >= 10_000.0 {
14553                Some(format!("{mb:.0} MB OST file detected"))
14554            } else {
14555                None
14556            }
14557        })
14558        .collect();
14559    for msg in large_ost {
14560        findings.push(format!(
14561            "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
14562        ));
14563    }
14564
14565    if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
14566        let active_count = line
14567            .split("Active: ")
14568            .nth(1)
14569            .and_then(|r| r.split(" |").next())
14570            .and_then(|v| v.trim().parse::<usize>().ok())
14571            .unwrap_or(0);
14572        if active_count >= 8 {
14573            findings.push(format!(
14574                "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
14575            ));
14576        }
14577    }
14578
14579    if out.contains("ResiliencyDisabledItems:") {
14580        findings.push(
14581            "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
14582                .into(),
14583        );
14584    }
14585
14586    if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
14587        findings.push(
14588            "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
14589                .into(),
14590        );
14591    }
14592
14593    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14594        findings.push(
14595            "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)."
14596                .into(),
14597        );
14598    }
14599
14600    let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
14601    if findings.is_empty() {
14602        result.push_str("- No obvious Outlook health blocker detected.\n");
14603    } else {
14604        for finding in &findings {
14605            result.push_str(&format!("- Finding: {finding}\n"));
14606        }
14607    }
14608    result.push('\n');
14609    result.push_str(&out);
14610    Ok(result)
14611}
14612
14613#[cfg(not(windows))]
14614fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
14615    Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
14616}
14617
14618#[cfg(windows)]
14619fn inspect_teams(max_entries: usize) -> Result<String, String> {
14620    let mut out = String::from("=== Teams install inventory ===\n");
14621
14622    let ps_install = r#"
14623# Classic Teams (Teams 1.0)
14624$classicExe = @(
14625    (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
14626    (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
14627) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14628
14629if ($classicExe) {
14630    $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
14631    "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
14632} else {
14633    "ClassicTeams: Not installed"
14634}
14635
14636# New Teams (Teams 2.0 / ms-teams.exe)
14637$newTeamsExe = @(
14638    (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
14639    (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
14640) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14641
14642$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
14643if ($newTeamsPkg) {
14644    "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
14645} elseif ($newTeamsExe) {
14646    $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
14647    "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
14648} else {
14649    "NewTeams: Not installed"
14650}
14651
14652# Teams Machine-Wide Installer (MSI/per-machine)
14653$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
14654    Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
14655    Select-Object -First 1
14656if ($mwi) {
14657    "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
14658} else {
14659    "MachineWideInstaller: Not found"
14660}
14661"#;
14662    match run_powershell(ps_install) {
14663        Ok(o) if !o.trim().is_empty() => {
14664            for line in o.lines().take(max_entries + 4) {
14665                let l = line.trim();
14666                if !l.is_empty() {
14667                    out.push_str(&format!("- {l}\n"));
14668                }
14669            }
14670        }
14671        _ => out.push_str("- Could not inspect Teams install paths\n"),
14672    }
14673
14674    out.push_str("\n=== Runtime state ===\n");
14675    let ps_runtime = r#"
14676$targets = @('Teams','ms-teams')
14677foreach ($name in $targets) {
14678    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14679    if ($procs) {
14680        $count = @($procs).Count
14681        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14682        "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
14683    } else {
14684        "$name | Running: No"
14685    }
14686}
14687"#;
14688    match run_powershell(ps_runtime) {
14689        Ok(o) if !o.trim().is_empty() => {
14690            for line in o.lines().take(6) {
14691                let l = line.trim();
14692                if !l.is_empty() {
14693                    out.push_str(&format!("- {l}\n"));
14694                }
14695            }
14696        }
14697        _ => out.push_str("- Could not inspect Teams runtime state\n"),
14698    }
14699
14700    out.push_str("\n=== Cache directory sizing ===\n");
14701    let ps_cache = r#"
14702$cachePaths = @(
14703    @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
14704    @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
14705    @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
14706    @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
14707)
14708foreach ($entry in $cachePaths) {
14709    if (Test-Path $entry.Path) {
14710        $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
14711        if (-not $sizeBytes) { $sizeBytes = 0 }
14712        $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
14713        "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
14714    } else {
14715        "$($entry.Name) | Path: $($entry.Path) | Not found"
14716    }
14717}
14718"#;
14719    match run_powershell(ps_cache) {
14720        Ok(o) if !o.trim().is_empty() => {
14721            for line in o.lines().take(max_entries + 4) {
14722                let l = line.trim();
14723                if !l.is_empty() {
14724                    out.push_str(&format!("- {l}\n"));
14725                }
14726            }
14727        }
14728        _ => out.push_str("- Could not inspect Teams cache directories\n"),
14729    }
14730
14731    out.push_str("\n=== WebView2 runtime ===\n");
14732    let ps_webview = r#"
14733$paths = @(
14734    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14735    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14736) | Where-Object { $_ -and (Test-Path $_) }
14737$runtimeDir = $paths | ForEach-Object {
14738    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14739        Where-Object { $_.Name -match '^\d+\.' } |
14740        Sort-Object Name -Descending |
14741        Select-Object -First 1
14742} | Select-Object -First 1
14743if ($runtimeDir) {
14744    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14745    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14746    "Installed: Yes | Version: $version"
14747} else {
14748    "Installed: No -- New Teams and some Office features require WebView2"
14749}
14750"#;
14751    match run_powershell(ps_webview) {
14752        Ok(o) if !o.trim().is_empty() => {
14753            for line in o.lines().take(4) {
14754                let l = line.trim();
14755                if !l.is_empty() {
14756                    out.push_str(&format!("- {l}\n"));
14757                }
14758            }
14759        }
14760        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14761    }
14762
14763    out.push_str("\n=== Account and sign-in state ===\n");
14764    let ps_auth = r#"
14765# Classic Teams account registry
14766$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14767if (Test-Path $classicAcct) {
14768    $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
14769    $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
14770    "ClassicTeamsAccount: $email"
14771} else {
14772    "ClassicTeamsAccount: Not configured"
14773}
14774# WAM / token broker state for Teams
14775$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14776$tokenCount = if (Test-Path $tokenCache) {
14777    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14778} else { 0 }
14779"TokenBrokerCacheFiles: $tokenCount"
14780# Office identity
14781$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14782if (Test-Path $officeId) {
14783    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14784    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14785    "OfficeSignedInUserId: $signedIn"
14786}
14787# Check if Teams is in startup
14788$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
14789$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
14790"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
14791"#;
14792    match run_powershell(ps_auth) {
14793        Ok(o) if !o.trim().is_empty() => {
14794            for line in o.lines().take(max_entries + 4) {
14795                let l = line.trim();
14796                if !l.is_empty() {
14797                    out.push_str(&format!("- {l}\n"));
14798                }
14799            }
14800        }
14801        _ => out.push_str("- Could not inspect Teams account state\n"),
14802    }
14803
14804    out.push_str("\n=== Audio and video device binding ===\n");
14805    let ps_devices = r#"
14806# Teams stores device prefs in the settings file
14807$settingsPaths = @(
14808    (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
14809    (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
14810)
14811$found = $false
14812foreach ($sp in $settingsPaths) {
14813    if (Test-Path $sp) {
14814        $found = $true
14815        $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
14816        if ($raw) {
14817            $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14818            if ($json) {
14819                $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14820                $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14821                $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14822                "ConfigFile: $sp"
14823                "Microphone: $mic"
14824                "Speaker: $spk"
14825                "Camera: $cam"
14826            } else {
14827                "ConfigFile: $sp (not parseable as JSON)"
14828            }
14829        } else {
14830            "ConfigFile: $sp (empty)"
14831        }
14832        break
14833    }
14834}
14835if (-not $found) {
14836    "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14837}
14838"#;
14839    match run_powershell(ps_devices) {
14840        Ok(o) if !o.trim().is_empty() => {
14841            for line in o.lines().take(max_entries + 4) {
14842                let l = line.trim();
14843                if !l.is_empty() {
14844                    out.push_str(&format!("- {l}\n"));
14845                }
14846            }
14847        }
14848        _ => out.push_str("- Could not inspect Teams device binding\n"),
14849    }
14850
14851    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14852    let ps_events = r#"
14853$cutoff = (Get-Date).AddDays(-7)
14854$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14855    Where-Object {
14856        $msg = [string]$_.Message
14857        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14858        ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14859    } |
14860    Select-Object -First 8
14861if ($events) {
14862    foreach ($event in $events) {
14863        $msg = ($event.Message -replace '\s+', ' ')
14864        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14865        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14866    }
14867} else {
14868    "No recent Teams crash or error events detected in Application log"
14869}
14870"#;
14871    match run_powershell(ps_events) {
14872        Ok(o) if !o.trim().is_empty() => {
14873            for line in o.lines().take(max_entries + 4) {
14874                let l = line.trim();
14875                if !l.is_empty() {
14876                    out.push_str(&format!("- {l}\n"));
14877                }
14878            }
14879        }
14880        _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14881    }
14882
14883    let mut findings: Vec<String> = Vec::new();
14884
14885    let classic_installed = out.contains("- ClassicTeams: Installed");
14886    let new_installed = out.contains("- NewTeams: Installed");
14887    if !classic_installed && !new_installed {
14888        findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14889    }
14890
14891    for name in ["Teams", "ms-teams"] {
14892        let marker = format!("{name} | Running: Yes | Processes:");
14893        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14894            let ws_mb = line
14895                .split("WorkingSetMB: ")
14896                .nth(1)
14897                .and_then(|v| v.trim().parse::<f64>().ok())
14898                .unwrap_or(0.0);
14899            if ws_mb >= 1000.0 {
14900                findings.push(format!(
14901                    "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
14902                ));
14903            }
14904        }
14905    }
14906
14907    for (label, threshold_mb) in [
14908        ("ClassicTeamsCache", 500.0_f64),
14909        ("ClassicTeamsSquirrel", 2000.0),
14910        ("NewTeamsCache", 500.0),
14911        ("NewTeamsAppData", 3000.0),
14912    ] {
14913        let marker = format!("{label} |");
14914        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14915            let mb = line
14916                .split("SizeMB: ")
14917                .nth(1)
14918                .and_then(|v| v.trim().parse::<f64>().ok())
14919                .unwrap_or(0.0);
14920            if mb >= threshold_mb {
14921                findings.push(format!(
14922                    "{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."
14923                ));
14924            }
14925        }
14926    }
14927
14928    if out.contains("- Installed: No -- New Teams") {
14929        findings.push(
14930            "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
14931                .into(),
14932        );
14933    }
14934
14935    if out.contains("- ClassicTeamsAccount: Not configured")
14936        && out.contains("- OfficeSignedInUserId: None")
14937    {
14938        findings.push(
14939            "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
14940                .into(),
14941        );
14942    }
14943
14944    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14945        findings.push(
14946            "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
14947                .into(),
14948        );
14949    }
14950
14951    let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
14952    if findings.is_empty() {
14953        result.push_str("- No obvious Teams health blocker detected.\n");
14954    } else {
14955        for finding in &findings {
14956            result.push_str(&format!("- Finding: {finding}\n"));
14957        }
14958    }
14959    result.push('\n');
14960    result.push_str(&out);
14961    Ok(result)
14962}
14963
14964#[cfg(not(windows))]
14965fn inspect_teams(_max_entries: usize) -> Result<String, String> {
14966    Ok(
14967        "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
14968            .into(),
14969    )
14970}
14971
14972#[cfg(windows)]
14973fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
14974    let mut out = String::from("=== Identity broker services ===\n");
14975
14976    let ps_services = r#"
14977$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
14978foreach ($name in $serviceNames) {
14979    $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14980    if ($svc) {
14981        "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
14982    } else {
14983        "$name | Not found"
14984    }
14985}
14986"#;
14987    match run_powershell(ps_services) {
14988        Ok(o) if !o.trim().is_empty() => {
14989            for line in o.lines().take(max_entries) {
14990                let l = line.trim();
14991                if !l.is_empty() {
14992                    out.push_str(&format!("- {l}\n"));
14993                }
14994            }
14995        }
14996        _ => out.push_str("- Could not inspect identity broker services\n"),
14997    }
14998
14999    out.push_str("\n=== Device registration ===\n");
15000    let ps_device = r#"
15001$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
15002if ($dsreg) {
15003    try {
15004        $raw = & $dsreg.Source /status 2>$null
15005        $text = ($raw -join "`n")
15006        $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
15007        $seen = $false
15008        foreach ($key in $keys) {
15009            $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
15010            if ($match.Success) {
15011                "${key}: $($match.Groups[1].Value.Trim())"
15012                $seen = $true
15013            }
15014        }
15015        if (-not $seen) {
15016            "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
15017        }
15018    } catch {
15019        "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
15020    }
15021} else {
15022    "DeviceRegistration: dsregcmd unavailable"
15023}
15024"#;
15025    match run_powershell(ps_device) {
15026        Ok(o) if !o.trim().is_empty() => {
15027            for line in o.lines().take(max_entries + 4) {
15028                let l = line.trim();
15029                if !l.is_empty() {
15030                    out.push_str(&format!("- {l}\n"));
15031                }
15032            }
15033        }
15034        _ => out.push_str(
15035            "- DeviceRegistration: Could not inspect device registration state in this session\n",
15036        ),
15037    }
15038
15039    out.push_str("\n=== Broker packages and caches ===\n");
15040    let ps_broker = r#"
15041$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
15042if ($pkg) {
15043    "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
15044} else {
15045    "AADBrokerPlugin: Not installed"
15046}
15047$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15048$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15049"TokenBrokerCacheFiles: $tokenCount"
15050$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
15051$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15052"IdentityCacheFiles: $identityCount"
15053$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
15054$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15055"OneAuthFiles: $oneAuthCount"
15056"#;
15057    match run_powershell(ps_broker) {
15058        Ok(o) if !o.trim().is_empty() => {
15059            for line in o.lines().take(max_entries + 4) {
15060                let l = line.trim();
15061                if !l.is_empty() {
15062                    out.push_str(&format!("- {l}\n"));
15063                }
15064            }
15065        }
15066        _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
15067    }
15068
15069    out.push_str("\n=== Microsoft app account signals ===\n");
15070    let ps_accounts = r#"
15071function MaskEmail([string]$Email) {
15072    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
15073    $parts = $Email.Split('@', 2)
15074    $local = $parts[0]
15075    $domain = $parts[1]
15076    if ($local.Length -le 1) { return "*@$domain" }
15077    return ($local.Substring(0,1) + "***@" + $domain)
15078}
15079$allAccounts = @()
15080$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15081if (Test-Path $officeId) {
15082    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
15083    if ($id.SignedInUserId) {
15084        $allAccounts += [string]$id.SignedInUserId
15085        "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
15086    } else {
15087        "OfficeSignedInUserId: None"
15088    }
15089} else {
15090    "OfficeSignedInUserId: Not configured"
15091}
15092$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
15093if (Test-Path $teamsAcct) {
15094    $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
15095    $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
15096    if (-not [string]::IsNullOrWhiteSpace($email)) {
15097        $allAccounts += $email
15098        "TeamsAccount: $(MaskEmail $email)"
15099    } else {
15100        "TeamsAccount: Unknown"
15101    }
15102} else {
15103    "TeamsAccount: Not configured"
15104}
15105$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
15106$oneDriveEmails = @()
15107if (Test-Path $oneDriveBase) {
15108    $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
15109        ForEach-Object {
15110            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
15111            if ($p.UserEmail) { [string]$p.UserEmail }
15112        } |
15113        Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
15114        Sort-Object -Unique
15115}
15116$allAccounts += $oneDriveEmails
15117"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
15118if (@($oneDriveEmails).Count -gt 0) {
15119    "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15120}
15121$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
15122"DistinctIdentityCount: $($distinct.Count)"
15123if ($distinct.Count -gt 0) {
15124    "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15125}
15126"#;
15127    match run_powershell(ps_accounts) {
15128        Ok(o) if !o.trim().is_empty() => {
15129            for line in o.lines().take(max_entries + 6) {
15130                let l = line.trim();
15131                if !l.is_empty() {
15132                    out.push_str(&format!("- {l}\n"));
15133                }
15134            }
15135        }
15136        _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
15137    }
15138
15139    out.push_str("\n=== WebView2 auth dependency ===\n");
15140    let ps_webview = r#"
15141$paths = @(
15142    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
15143    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
15144) | Where-Object { $_ -and (Test-Path $_) }
15145$runtimeDir = $paths | ForEach-Object {
15146    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
15147        Where-Object { $_.Name -match '^\d+\.' } |
15148        Sort-Object Name -Descending |
15149        Select-Object -First 1
15150} | Select-Object -First 1
15151if ($runtimeDir) {
15152    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
15153    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
15154    "WebView2: Installed | Version: $version"
15155} else {
15156    "WebView2: Not installed"
15157}
15158"#;
15159    match run_powershell(ps_webview) {
15160        Ok(o) if !o.trim().is_empty() => {
15161            for line in o.lines().take(4) {
15162                let l = line.trim();
15163                if !l.is_empty() {
15164                    out.push_str(&format!("- {l}\n"));
15165                }
15166            }
15167        }
15168        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
15169    }
15170
15171    out.push_str("\n=== Recent auth-related events (24h) ===\n");
15172    let ps_events = r#"
15173try {
15174    $cutoff = (Get-Date).AddHours(-24)
15175    $events = @()
15176    if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
15177        $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
15178            Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
15179            Select-Object -First 4
15180    }
15181    $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
15182        Where-Object {
15183            ($_.LevelDisplayName -in @('Error','Warning')) -and (
15184                $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
15185                -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
15186            )
15187        } |
15188        Select-Object -First 6
15189    $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
15190    "AuthEventCount: $(@($events).Count)"
15191    if ($events) {
15192        foreach ($e in $events) {
15193            $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
15194                'No message'
15195            } else {
15196                ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
15197            }
15198            "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
15199        }
15200    } else {
15201        "No auth-related warning/error events detected"
15202    }
15203} catch {
15204    "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
15205}
15206"#;
15207    match run_powershell(ps_events) {
15208        Ok(o) if !o.trim().is_empty() => {
15209            for line in o.lines().take(max_entries + 8) {
15210                let l = line.trim();
15211                if !l.is_empty() {
15212                    out.push_str(&format!("- {l}\n"));
15213                }
15214            }
15215        }
15216        _ => out
15217            .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
15218    }
15219
15220    let parse_count = |prefix: &str| -> Option<u64> {
15221        out.lines().find_map(|line| {
15222            line.trim()
15223                .strip_prefix(prefix)
15224                .and_then(|value| value.trim().parse::<u64>().ok())
15225        })
15226    };
15227
15228    let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
15229    let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
15230
15231    let mut findings: Vec<String> = Vec::new();
15232    if out.contains("TokenBroker | Status: Stopped")
15233        || out.contains("wlidsvc | Status: Stopped")
15234        || out.contains("OneAuth | Status: Stopped")
15235    {
15236        findings.push(
15237            "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."
15238                .into(),
15239        );
15240    }
15241    if out.contains("AADBrokerPlugin: Not installed") {
15242        findings.push(
15243            "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
15244                .into(),
15245        );
15246    }
15247    if out.contains("WebView2: Not installed") {
15248        findings.push(
15249            "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
15250                .into(),
15251        );
15252    }
15253    if distinct_identity_count > 1 {
15254        findings.push(format!(
15255            "{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."
15256        ));
15257    }
15258    if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
15259        && distinct_identity_count > 0
15260    {
15261        findings.push(
15262            "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
15263                .into(),
15264        );
15265    }
15266    if out.contains("DeviceRegistration: dsregcmd")
15267        || out.contains("DeviceRegistration: Could not inspect device registration state")
15268    {
15269        findings.push(
15270            "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."
15271                .into(),
15272        );
15273    }
15274    if auth_event_count > 0 {
15275        findings.push(format!(
15276            "{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."
15277        ));
15278    } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
15279        findings.push(
15280            "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."
15281                .into(),
15282        );
15283    }
15284
15285    let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
15286    if findings.is_empty() {
15287        result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
15288    } else {
15289        for finding in &findings {
15290            result.push_str(&format!("- Finding: {finding}\n"));
15291        }
15292    }
15293    result.push('\n');
15294    result.push_str(&out);
15295    Ok(result)
15296}
15297
15298#[cfg(not(windows))]
15299fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
15300    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())
15301}
15302
15303#[cfg(windows)]
15304fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15305    let mut out = String::from("=== File History ===\n");
15306
15307    let ps_fh = r#"
15308$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
15309if ($svc) {
15310    "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
15311} else {
15312    "FileHistoryService: Not found"
15313}
15314# File History config in registry
15315$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
15316$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
15317if (Test-Path $fhUser) {
15318    $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
15319    $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
15320    $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
15321    $lastBackup = if ($fh.ProtectedUpToTime) {
15322        try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
15323    } else { 'Never' }
15324    "Enabled: $enabled"
15325    "BackupDrive: $target"
15326    "LastBackup: $lastBackup"
15327} else {
15328    "Enabled: Not configured"
15329    "BackupDrive: Not configured"
15330    "LastBackup: Never"
15331}
15332"#;
15333    match run_powershell(ps_fh) {
15334        Ok(o) if !o.trim().is_empty() => {
15335            for line in o.lines().take(6) {
15336                let l = line.trim();
15337                if !l.is_empty() {
15338                    out.push_str(&format!("- {l}\n"));
15339                }
15340            }
15341        }
15342        _ => out.push_str("- Could not inspect File History state\n"),
15343    }
15344
15345    out.push_str("\n=== Windows Backup (wbadmin) ===\n");
15346    let ps_wbadmin = r#"
15347$svc = Get-Service wbengine -ErrorAction SilentlyContinue
15348"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
15349# Last backup from wbadmin
15350$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
15351if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
15352    $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
15353    $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
15354    if ($lastDate) { $lastDate.Trim() }
15355    if ($lastTarget) { $lastTarget.Trim() }
15356} else {
15357    "LastWbadminBackup: No backup versions found"
15358}
15359# Task-based backup
15360$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
15361foreach ($t in $task) {
15362    "BackupTask: $($t.TaskName) | State: $($t.State)"
15363}
15364"#;
15365    match run_powershell(ps_wbadmin) {
15366        Ok(o) if !o.trim().is_empty() => {
15367            for line in o.lines().take(8) {
15368                let l = line.trim();
15369                if !l.is_empty() {
15370                    out.push_str(&format!("- {l}\n"));
15371                }
15372            }
15373        }
15374        _ => out.push_str("- Could not inspect Windows Backup state\n"),
15375    }
15376
15377    out.push_str("\n=== System Restore ===\n");
15378    let ps_sr = r#"
15379$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
15380    Select-Object -ExpandProperty DeviceID
15381foreach ($drive in $drives) {
15382    $protection = try {
15383        (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
15384    } catch { $null }
15385    $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
15386    $rpConf = try {
15387        Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
15388    } catch { $null }
15389    # Check if SR is disabled for this drive
15390    $disabled = $false
15391    $vssService = Get-Service VSS -ErrorAction SilentlyContinue
15392    "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
15393}
15394# Most recent restore point
15395$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
15396if ($points) {
15397    $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
15398    $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
15399    "MostRecentRestorePoint: $($latest.Description) | Created: $date"
15400} else {
15401    "MostRecentRestorePoint: None found"
15402}
15403$srEnabled = try {
15404    $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
15405    if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
15406} catch { 'Unknown' }
15407"SystemRestoreState: $srEnabled"
15408"#;
15409    match run_powershell(ps_sr) {
15410        Ok(o) if !o.trim().is_empty() => {
15411            for line in o.lines().take(8) {
15412                let l = line.trim();
15413                if !l.is_empty() {
15414                    out.push_str(&format!("- {l}\n"));
15415                }
15416            }
15417        }
15418        _ => out.push_str("- Could not inspect System Restore state\n"),
15419    }
15420
15421    out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
15422    let ps_kfm = r#"
15423$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
15424if (Test-Path $kfmKey) {
15425    $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
15426    foreach ($acct in $accounts | Select-Object -First 3) {
15427        $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
15428        $email = $props.UserEmail
15429        $kfmDesktop = $props.'KFMSilentOptInDesktop'
15430        $kfmDocs = $props.'KFMSilentOptInDocuments'
15431        $kfmPics = $props.'KFMSilentOptInPictures'
15432        "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' })"
15433    }
15434} else {
15435    "OneDriveKFM: No OneDrive accounts found"
15436}
15437"#;
15438    match run_powershell(ps_kfm) {
15439        Ok(o) if !o.trim().is_empty() => {
15440            for line in o.lines().take(6) {
15441                let l = line.trim();
15442                if !l.is_empty() {
15443                    out.push_str(&format!("- {l}\n"));
15444                }
15445            }
15446        }
15447        _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
15448    }
15449
15450    out.push_str("\n=== Recent backup failure events (7d) ===\n");
15451    let ps_events = r#"
15452$cutoff = (Get-Date).AddDays(-7)
15453$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15454    Where-Object {
15455        $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
15456        ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
15457    } |
15458    Where-Object { $_.Level -le 3 } |
15459    Select-Object -First 6
15460if ($events) {
15461    foreach ($event in $events) {
15462        $msg = ($event.Message -replace '\s+', ' ')
15463        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15464        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15465    }
15466} else {
15467    "No recent backup failure events detected"
15468}
15469"#;
15470    match run_powershell(ps_events) {
15471        Ok(o) if !o.trim().is_empty() => {
15472            for line in o.lines().take(8) {
15473                let l = line.trim();
15474                if !l.is_empty() {
15475                    out.push_str(&format!("- {l}\n"));
15476                }
15477            }
15478        }
15479        _ => out.push_str("- Could not inspect backup failure events\n"),
15480    }
15481
15482    let mut findings: Vec<String> = Vec::new();
15483
15484    let fh_enabled = out.contains("- Enabled: Enabled");
15485    let fh_never =
15486        out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
15487    let no_wbadmin = out.contains("No backup versions found");
15488    let no_restore_point = out.contains("MostRecentRestorePoint: None found");
15489
15490    if !fh_enabled && no_wbadmin {
15491        findings.push(
15492            "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(),
15493        );
15494    } else if fh_enabled && fh_never {
15495        findings.push(
15496            "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
15497        );
15498    }
15499
15500    if no_restore_point {
15501        findings.push(
15502            "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
15503        );
15504    }
15505
15506    if out.contains("- FileHistoryService: Stopped")
15507        || out.contains("- FileHistoryService: Not found")
15508    {
15509        findings.push(
15510            "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
15511        );
15512    }
15513
15514    if out.contains("Application Error |")
15515        || out.contains("Microsoft-Windows-Backup |")
15516        || out.contains("wbengine |")
15517    {
15518        findings.push(
15519            "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
15520        );
15521    }
15522
15523    let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
15524    if findings.is_empty() {
15525        result.push_str("- No obvious backup health blocker detected.\n");
15526    } else {
15527        for finding in &findings {
15528            result.push_str(&format!("- Finding: {finding}\n"));
15529        }
15530    }
15531    result.push('\n');
15532    result.push_str(&out);
15533    Ok(result)
15534}
15535
15536#[cfg(not(windows))]
15537fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15538    Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
15539}
15540
15541#[cfg(windows)]
15542fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15543    let mut out = String::from("=== Windows Search service ===\n");
15544
15545    // Service state
15546    let ps_svc = r#"
15547$svc = Get-Service WSearch -ErrorAction SilentlyContinue
15548if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15549else { "WSearch service not found" }
15550"#;
15551    match run_powershell(ps_svc) {
15552        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15553        Err(_) => out.push_str("- Could not query WSearch service\n"),
15554    }
15555
15556    // Indexer state via registry
15557    out.push_str("\n=== Indexer state ===\n");
15558    let ps_idx = r#"
15559$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
15560$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
15561if ($props) {
15562    "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
15563    "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
15564    "DataDirectory: $($props.DataDirectory)"
15565} else { "Registry key not found" }
15566"#;
15567    match run_powershell(ps_idx) {
15568        Ok(o) => {
15569            for line in o.lines() {
15570                let l = line.trim();
15571                if !l.is_empty() {
15572                    out.push_str(&format!("- {l}\n"));
15573                }
15574            }
15575        }
15576        Err(_) => out.push_str("- Could not read indexer registry\n"),
15577    }
15578
15579    // Indexed locations
15580    out.push_str("\n=== Indexed locations ===\n");
15581    let ps_locs = r#"
15582$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
15583if ($comObj) {
15584    $catalog = $comObj.GetCatalog('SystemIndex')
15585    $manager = $catalog.GetCrawlScopeManager()
15586    $rules = $manager.EnumerateRoots()
15587    while ($true) {
15588        try {
15589            $root = $rules.Next(1)
15590            if ($root.Count -eq 0) { break }
15591            $r = $root[0]
15592            "  $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
15593        } catch { break }
15594    }
15595} else { "  COM admin interface not available (normal on non-admin sessions)" }
15596"#;
15597    match run_powershell(ps_locs) {
15598        Ok(o) if !o.trim().is_empty() => {
15599            for line in o.lines() {
15600                let l = line.trim_end();
15601                if !l.is_empty() {
15602                    out.push_str(&format!("{l}\n"));
15603                }
15604            }
15605        }
15606        _ => {
15607            // Fallback: read from registry
15608            let ps_reg = r#"
15609Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
15610ForEach-Object { "  $($_.PSChildName)" } | Select-Object -First 20
15611"#;
15612            match run_powershell(ps_reg) {
15613                Ok(o) if !o.trim().is_empty() => {
15614                    for line in o.lines() {
15615                        let l = line.trim_end();
15616                        if !l.is_empty() {
15617                            out.push_str(&format!("{l}\n"));
15618                        }
15619                    }
15620                }
15621                _ => out.push_str("  - Could not enumerate indexed locations\n"),
15622            }
15623        }
15624    }
15625
15626    // Recent indexing errors from event log
15627    out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
15628    let ps_evts = r#"
15629Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
15630Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
15631ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
15632"#;
15633    match run_powershell(ps_evts) {
15634        Ok(o) if !o.trim().is_empty() => {
15635            for line in o.lines() {
15636                let l = line.trim();
15637                if !l.is_empty() {
15638                    out.push_str(&format!("- {l}\n"));
15639                }
15640            }
15641        }
15642        _ => out.push_str("- No recent indexer errors found\n"),
15643    }
15644
15645    let mut findings: Vec<String> = Vec::new();
15646    if out.contains("Status: Stopped") {
15647        findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
15648    }
15649    if out.contains("IsContentIndexingEnabled: 0")
15650        || out.contains("IsContentIndexingEnabled: False")
15651    {
15652        findings.push(
15653            "Content indexing is disabled — file content won't be searchable, only filenames."
15654                .into(),
15655        );
15656    }
15657    if out.contains("SetupCompletedSuccessfully: 0")
15658        || out.contains("SetupCompletedSuccessfully: False")
15659    {
15660        findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
15661    }
15662
15663    let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
15664    if findings.is_empty() {
15665        result.push_str("- Windows Search service and indexer appear healthy.\n");
15666        result.push_str("  If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
15667    } else {
15668        for f in &findings {
15669            result.push_str(&format!("- Finding: {f}\n"));
15670        }
15671    }
15672    result.push('\n');
15673    result.push_str(&out);
15674    Ok(result)
15675}
15676
15677#[cfg(not(windows))]
15678fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15679    Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
15680}
15681
15682// ── inspect_display_config ────────────────────────────────────────────────────
15683
15684#[cfg(windows)]
15685fn inspect_display_config(max_entries: usize) -> Result<String, String> {
15686    let mut out = String::new();
15687
15688    // Active displays via CIM
15689    out.push_str("=== Active displays ===\n");
15690    let ps_displays = r#"
15691Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
15692Select-Object -First 20 |
15693ForEach-Object {
15694    "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
15695}
15696"#;
15697    match run_powershell(ps_displays) {
15698        Ok(o) if !o.trim().is_empty() => {
15699            for line in o.lines().take(max_entries) {
15700                let l = line.trim();
15701                if !l.is_empty() {
15702                    out.push_str(&format!("- {l}\n"));
15703                }
15704            }
15705        }
15706        _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
15707    }
15708
15709    // GPU / video adapter
15710    out.push_str("\n=== Video adapters ===\n");
15711    let ps_gpu = r#"
15712Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
15713ForEach-Object {
15714    $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
15715    $hz  = "$($_.CurrentRefreshRate) Hz"
15716    $bits = "$($_.CurrentBitsPerPixel) bpp"
15717    "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
15718}
15719"#;
15720    match run_powershell(ps_gpu) {
15721        Ok(o) if !o.trim().is_empty() => {
15722            for line in o.lines().take(max_entries) {
15723                let l = line.trim();
15724                if !l.is_empty() {
15725                    out.push_str(&format!("- {l}\n"));
15726                }
15727            }
15728        }
15729        _ => out.push_str("- Could not query video adapter info\n"),
15730    }
15731
15732    // Monitor names via Win32_DesktopMonitor
15733    out.push_str("\n=== Connected monitors ===\n");
15734    let ps_monitors = r#"
15735Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
15736ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
15737"#;
15738    match run_powershell(ps_monitors) {
15739        Ok(o) if !o.trim().is_empty() => {
15740            for line in o.lines().take(max_entries) {
15741                let l = line.trim();
15742                if !l.is_empty() {
15743                    out.push_str(&format!("- {l}\n"));
15744                }
15745            }
15746        }
15747        _ => out.push_str("- No monitor info available via WMI\n"),
15748    }
15749
15750    // DPI scaling
15751    out.push_str("\n=== DPI / scaling ===\n");
15752    let ps_dpi = r#"
15753Add-Type -TypeDefinition @'
15754using System; using System.Runtime.InteropServices;
15755public class DPI {
15756    [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
15757    [DllImport("gdi32")]  public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
15758    [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
15759}
15760'@ -ErrorAction SilentlyContinue
15761try {
15762    $hdc  = [DPI]::GetDC([IntPtr]::Zero)
15763    $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
15764    $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
15765    [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
15766    $scale = [Math]::Round($dpiX / 96.0 * 100)
15767    "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
15768} catch { "DPI query unavailable" }
15769"#;
15770    match run_powershell(ps_dpi) {
15771        Ok(o) if !o.trim().is_empty() => {
15772            out.push_str(&format!("- {}\n", o.trim()));
15773        }
15774        _ => out.push_str("- DPI info unavailable\n"),
15775    }
15776
15777    let mut findings: Vec<String> = Vec::new();
15778    if out.contains("0x0") || out.contains("@ 0 Hz") {
15779        findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
15780    }
15781
15782    let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
15783    if findings.is_empty() {
15784        result.push_str("- Display configuration appears normal.\n");
15785    } else {
15786        for f in &findings {
15787            result.push_str(&format!("- Finding: {f}\n"));
15788        }
15789    }
15790    result.push('\n');
15791    result.push_str(&out);
15792    Ok(result)
15793}
15794
15795#[cfg(not(windows))]
15796fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
15797    Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
15798}
15799
15800// ── inspect_ntp ───────────────────────────────────────────────────────────────
15801
15802#[cfg(windows)]
15803fn inspect_ntp() -> Result<String, String> {
15804    let mut out = String::new();
15805
15806    // w32tm status
15807    out.push_str("=== Windows Time service ===\n");
15808    let ps_svc = r#"
15809$svc = Get-Service W32Time -ErrorAction SilentlyContinue
15810if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15811else { "W32Time service not found" }
15812"#;
15813    match run_powershell(ps_svc) {
15814        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15815        Err(_) => out.push_str("- Could not query W32Time service\n"),
15816    }
15817
15818    // NTP source and last sync
15819    out.push_str("\n=== NTP source and sync status ===\n");
15820    let ps_sync = r#"
15821$q = w32tm /query /status 2>$null
15822if ($q) { $q } else { "w32tm query unavailable" }
15823"#;
15824    match run_powershell(ps_sync) {
15825        Ok(o) if !o.trim().is_empty() => {
15826            for line in o.lines() {
15827                let l = line.trim();
15828                if !l.is_empty() {
15829                    out.push_str(&format!("  {l}\n"));
15830                }
15831            }
15832        }
15833        _ => out.push_str("  - Could not query w32tm status\n"),
15834    }
15835
15836    // Configured NTP server
15837    out.push_str("\n=== Configured NTP servers ===\n");
15838    let ps_peers = r#"
15839w32tm /query /peers 2>$null | Select-Object -First 10
15840"#;
15841    match run_powershell(ps_peers) {
15842        Ok(o) if !o.trim().is_empty() => {
15843            for line in o.lines() {
15844                let l = line.trim();
15845                if !l.is_empty() {
15846                    out.push_str(&format!("  {l}\n"));
15847                }
15848            }
15849        }
15850        _ => {
15851            // Fallback: registry
15852            let ps_reg = r#"
15853(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15854"#;
15855            match run_powershell(ps_reg) {
15856                Ok(o) if !o.trim().is_empty() => {
15857                    out.push_str(&format!("  NtpServer (registry): {}\n", o.trim()));
15858                }
15859                _ => out.push_str("  - Could not enumerate NTP peers\n"),
15860            }
15861        }
15862    }
15863
15864    let mut findings: Vec<String> = Vec::new();
15865    if out.contains("W32Time | Status: Stopped") {
15866        findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15867    }
15868    if out.contains("The computer did not resync") || out.contains("Error") {
15869        findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15870    }
15871
15872    let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15873    if findings.is_empty() {
15874        result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15875    } else {
15876        for f in &findings {
15877            result.push_str(&format!("- Finding: {f}\n"));
15878        }
15879    }
15880    result.push('\n');
15881    result.push_str(&out);
15882    Ok(result)
15883}
15884
15885#[cfg(not(windows))]
15886fn inspect_ntp() -> Result<String, String> {
15887    // Linux/macOS: check timedatectl / chrony / ntpq
15888    let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15889
15890    let timedatectl = std::process::Command::new("timedatectl")
15891        .arg("status")
15892        .output();
15893
15894    if let Ok(o) = timedatectl {
15895        let text = String::from_utf8_lossy(&o.stdout);
15896        if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
15897            out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
15898        } else {
15899            out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
15900        }
15901        for line in text.lines() {
15902            let l = line.trim();
15903            if !l.is_empty() {
15904                out.push_str(&format!("  {l}\n"));
15905            }
15906        }
15907        return Ok(out);
15908    }
15909
15910    // macOS fallback
15911    let sntp = std::process::Command::new("sntp")
15912        .args(["-d", "time.apple.com"])
15913        .output();
15914    if let Ok(o) = sntp {
15915        out.push_str("- NTP check via sntp:\n");
15916        out.push_str(&String::from_utf8_lossy(&o.stdout));
15917        return Ok(out);
15918    }
15919
15920    out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
15921    Ok(out)
15922}
15923
15924// ── inspect_cpu_power ─────────────────────────────────────────────────────────
15925
15926#[cfg(windows)]
15927fn inspect_cpu_power() -> Result<String, String> {
15928    let mut out = String::new();
15929
15930    // Active power plan
15931    out.push_str("=== Active power plan ===\n");
15932    let ps_plan = r#"
15933$plan = powercfg /getactivescheme 2>$null
15934if ($plan) { $plan } else { "Could not query power scheme" }
15935"#;
15936    match run_powershell(ps_plan) {
15937        Ok(o) if !o.trim().is_empty() => out.push_str(&format!("- {}\n", o.trim())),
15938        _ => out.push_str("- Could not read active power plan\n"),
15939    }
15940
15941    // Processor min/max state and boost policy
15942    out.push_str("\n=== Processor performance policy ===\n");
15943    let ps_proc = r#"
15944$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
15945$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15946$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15947$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15948if ($min)   { "Min processor state:  $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
15949if ($max)   { "Max processor state:  $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
15950if ($boost) {
15951    $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
15952    $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
15953    "Turbo boost mode:     $bname"
15954}
15955"#;
15956    match run_powershell(ps_proc) {
15957        Ok(o) if !o.trim().is_empty() => {
15958            for line in o.lines() {
15959                let l = line.trim();
15960                if !l.is_empty() {
15961                    out.push_str(&format!("- {l}\n"));
15962                }
15963            }
15964        }
15965        _ => out.push_str("- Could not query processor performance settings\n"),
15966    }
15967
15968    // Current CPU frequency via WMI
15969    out.push_str("\n=== CPU frequency ===\n");
15970    let ps_freq = r#"
15971Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
15972ForEach-Object {
15973    $cur = $_.CurrentClockSpeed
15974    $max = $_.MaxClockSpeed
15975    $load = $_.LoadPercentage
15976    "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
15977}
15978"#;
15979    match run_powershell(ps_freq) {
15980        Ok(o) if !o.trim().is_empty() => {
15981            for line in o.lines() {
15982                let l = line.trim();
15983                if !l.is_empty() {
15984                    out.push_str(&format!("- {l}\n"));
15985                }
15986            }
15987        }
15988        _ => out.push_str("- Could not query CPU frequency via WMI\n"),
15989    }
15990
15991    // Throttle reason from ETW (quick check)
15992    out.push_str("\n=== Throttling indicators ===\n");
15993    let ps_throttle = r#"
15994$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
15995if ($pwr) {
15996    $pwr | Select-Object -First 4 | ForEach-Object {
15997        $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
15998        "Thermal zone $($_.InstanceName): ${c}°C"
15999    }
16000} else { "Thermal zone WMI not available (normal on consumer hardware)" }
16001"#;
16002    match run_powershell(ps_throttle) {
16003        Ok(o) if !o.trim().is_empty() => {
16004            for line in o.lines() {
16005                let l = line.trim();
16006                if !l.is_empty() {
16007                    out.push_str(&format!("- {l}\n"));
16008                }
16009            }
16010        }
16011        _ => out.push_str("- Thermal zone info unavailable\n"),
16012    }
16013
16014    let mut findings: Vec<String> = Vec::new();
16015    if out.contains("Max processor state:  0%") || out.contains("Max processor state:  1%") {
16016        findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
16017    }
16018    if out.contains("Turbo boost mode:     Disabled") {
16019        findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
16020    }
16021    if out.contains("Min processor state:  100%") {
16022        findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
16023    }
16024
16025    let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
16026    if findings.is_empty() {
16027        result.push_str("- CPU power and frequency settings appear normal.\n");
16028    } else {
16029        for f in &findings {
16030            result.push_str(&format!("- Finding: {f}\n"));
16031        }
16032    }
16033    result.push('\n');
16034    result.push_str(&out);
16035    Ok(result)
16036}
16037
16038#[cfg(windows)]
16039fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16040    let mut out = String::new();
16041
16042    out.push_str("=== Credential vault summary ===\n");
16043    let ps_summary = r#"
16044$raw = cmdkey /list 2>&1
16045$lines = $raw -split "`n"
16046$total = ($lines | Where-Object { $_ -match "Target:" }).Count
16047"Total stored credentials: $total"
16048$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
16049$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
16050$cert    = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
16051"  Windows credentials: $windows"
16052"  Generic credentials: $generic"
16053"  Certificate-based:   $cert"
16054"#;
16055    match run_powershell(ps_summary) {
16056        Ok(o) => {
16057            for line in o.lines() {
16058                let l = line.trim();
16059                if !l.is_empty() {
16060                    out.push_str(&format!("- {l}\n"));
16061                }
16062            }
16063        }
16064        Err(e) => out.push_str(&format!("- Credential summary error: {e}\n")),
16065    }
16066
16067    out.push_str("\n=== Credential targets (up to 20) ===\n");
16068    let ps_list = r#"
16069$raw = cmdkey /list 2>&1
16070$entries = @(); $cur = @{}
16071foreach ($line in ($raw -split "`n")) {
16072    $l = $line.Trim()
16073    if     ($l -match "^Target:\s*(.+)")  { $cur = @{ Target=$Matches[1] } }
16074    elseif ($l -match "^Type:\s*(.+)"   -and $cur.Target) { $cur.Type=$Matches[1] }
16075    elseif ($l -match "^User:\s*(.+)"   -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
16076}
16077$entries | Select-Object -Last 20 | ForEach-Object {
16078    "[$($_.Type)] $($_.Target)  (user: $($_.User))"
16079}
16080"#;
16081    match run_powershell(ps_list) {
16082        Ok(o) => {
16083            let lines: Vec<&str> = o
16084                .lines()
16085                .map(|l| l.trim())
16086                .filter(|l| !l.is_empty())
16087                .collect();
16088            if lines.is_empty() {
16089                out.push_str("- No credential entries found\n");
16090            } else {
16091                for l in &lines {
16092                    out.push_str(&format!("- {l}\n"));
16093                }
16094            }
16095        }
16096        Err(e) => out.push_str(&format!("- Credential list error: {e}\n")),
16097    }
16098
16099    let total_creds: usize = {
16100        let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
16101        run_powershell(ps_count)
16102            .ok()
16103            .and_then(|s| s.trim().parse().ok())
16104            .unwrap_or(0)
16105    };
16106
16107    let mut findings: Vec<String> = Vec::new();
16108    if total_creds > 30 {
16109        findings.push(format!(
16110            "{total_creds} stored credentials found — consider auditing for stale entries."
16111        ));
16112    }
16113
16114    let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
16115    if findings.is_empty() {
16116        result.push_str("- Credential store looks normal.\n");
16117    } else {
16118        for f in &findings {
16119            result.push_str(&format!("- Finding: {f}\n"));
16120        }
16121    }
16122    result.push('\n');
16123    result.push_str(&out);
16124    Ok(result)
16125}
16126
16127#[cfg(not(windows))]
16128fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16129    Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
16130}
16131
16132#[cfg(windows)]
16133fn inspect_tpm() -> Result<String, String> {
16134    let mut out = String::new();
16135
16136    out.push_str("=== TPM state ===\n");
16137    let ps_tpm = r#"
16138function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
16139    $text = if ($null -eq $Value) { "" } else { [string]$Value }
16140    if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
16141    "$Name$text"
16142}
16143$t = Get-Tpm -ErrorAction SilentlyContinue
16144if ($t) {
16145    Emit-Field "TpmPresent:          " $t.TpmPresent
16146    Emit-Field "TpmReady:            " $t.TpmReady
16147    Emit-Field "TpmEnabled:          " $t.TpmEnabled
16148    Emit-Field "TpmOwned:            " $t.TpmOwned
16149    Emit-Field "RestartPending:      " $t.RestartPending
16150    Emit-Field "ManufacturerIdTxt:   " $t.ManufacturerIdTxt
16151    Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
16152} else { "TPM module unavailable" }
16153"#;
16154    match run_powershell(ps_tpm) {
16155        Ok(o) => {
16156            for line in o.lines() {
16157                let l = line.trim();
16158                if !l.is_empty() {
16159                    out.push_str(&format!("- {l}\n"));
16160                }
16161            }
16162        }
16163        Err(e) => out.push_str(&format!("- Get-Tpm error: {e}\n")),
16164    }
16165
16166    out.push_str("\n=== TPM spec version (WMI) ===\n");
16167    let ps_spec = r#"
16168$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
16169if ($wmi) {
16170    $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
16171    "SpecVersion:  $spec"
16172    "IsActivated:  $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
16173    "IsEnabled:    $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
16174    "IsOwned:      $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
16175} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
16176"#;
16177    match run_powershell(ps_spec) {
16178        Ok(o) => {
16179            for line in o.lines() {
16180                let l = line.trim();
16181                if !l.is_empty() {
16182                    out.push_str(&format!("- {l}\n"));
16183                }
16184            }
16185        }
16186        Err(e) => out.push_str(&format!("- Win32_Tpm WMI error: {e}\n")),
16187    }
16188
16189    out.push_str("\n=== Secure Boot state ===\n");
16190    let ps_sb = r#"
16191try {
16192    $sb = Confirm-SecureBootUEFI -ErrorAction Stop
16193    if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
16194} catch {
16195    $msg = $_.Exception.Message
16196    if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
16197        "Secure Boot: Unknown (administrator privileges required)"
16198    } elseif ($msg -match "Cmdlet not supported on this platform") {
16199        "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
16200    } else {
16201        "Secure Boot: N/A ($msg)"
16202    }
16203}
16204"#;
16205    match run_powershell(ps_sb) {
16206        Ok(o) => {
16207            for line in o.lines() {
16208                let l = line.trim();
16209                if !l.is_empty() {
16210                    out.push_str(&format!("- {l}\n"));
16211                }
16212            }
16213        }
16214        Err(e) => out.push_str(&format!("- Secure Boot check error: {e}\n")),
16215    }
16216
16217    out.push_str("\n=== Firmware type ===\n");
16218    let ps_fw = r#"
16219$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
16220switch ($fw) {
16221    1 { "Firmware type: BIOS (Legacy)" }
16222    2 { "Firmware type: UEFI" }
16223    default {
16224        $bcd = bcdedit /enum firmware 2>$null
16225        if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
16226        else { "Firmware type: Unknown or not set" }
16227    }
16228}
16229"#;
16230    match run_powershell(ps_fw) {
16231        Ok(o) => {
16232            for line in o.lines() {
16233                let l = line.trim();
16234                if !l.is_empty() {
16235                    out.push_str(&format!("- {l}\n"));
16236                }
16237            }
16238        }
16239        Err(e) => out.push_str(&format!("- Firmware type error: {e}\n")),
16240    }
16241
16242    let mut findings: Vec<String> = Vec::new();
16243    let mut indeterminate = false;
16244    if out.contains("TpmPresent:          False") {
16245        findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
16246    }
16247    if out.contains("TpmReady:            False") {
16248        findings.push(
16249            "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
16250        );
16251    }
16252    if out.contains("SpecVersion:  1.2") {
16253        findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
16254    }
16255    if out.contains("Secure Boot: DISABLED") {
16256        findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
16257    }
16258    if out.contains("Firmware type: BIOS (Legacy)") {
16259        findings.push(
16260            "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
16261        );
16262    }
16263
16264    if out.contains("TPM module unavailable")
16265        || out.contains("Win32_Tpm WMI class unavailable")
16266        || out.contains("Secure Boot: N/A")
16267        || out.contains("Secure Boot: Unknown")
16268        || out.contains("Firmware type: Unknown or not set")
16269        || out.contains("TpmPresent:          Unknown")
16270        || out.contains("TpmReady:            Unknown")
16271        || out.contains("TpmEnabled:          Unknown")
16272    {
16273        indeterminate = true;
16274    }
16275    if indeterminate {
16276        findings.push(
16277            "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
16278                .into(),
16279        );
16280    }
16281
16282    let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
16283    if findings.is_empty() {
16284        result.push_str("- TPM and Secure Boot appear healthy.\n");
16285    } else {
16286        for f in &findings {
16287            result.push_str(&format!("- Finding: {f}\n"));
16288        }
16289    }
16290    result.push('\n');
16291    result.push_str(&out);
16292    Ok(result)
16293}
16294
16295#[cfg(not(windows))]
16296fn inspect_tpm() -> Result<String, String> {
16297    Ok(
16298        "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
16299            .into(),
16300    )
16301}
16302
16303#[cfg(windows)]
16304fn inspect_latency() -> Result<String, String> {
16305    let mut out = String::new();
16306
16307    // Resolve default gateway from the routing table
16308    let ps_gw = r#"
16309$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
16310       Sort-Object RouteMetric | Select-Object -First 1).NextHop
16311if ($gw) { $gw } else { "" }
16312"#;
16313    let gateway = run_powershell(ps_gw)
16314        .ok()
16315        .map(|s| s.trim().to_string())
16316        .filter(|s| !s.is_empty());
16317
16318    let targets: Vec<(&str, String)> = {
16319        let mut t = Vec::new();
16320        if let Some(ref gw) = gateway {
16321            t.push(("Default gateway", gw.clone()));
16322        }
16323        t.push(("Cloudflare DNS", "1.1.1.1".into()));
16324        t.push(("Google DNS", "8.8.8.8".into()));
16325        t
16326    };
16327
16328    let mut findings: Vec<String> = Vec::new();
16329
16330    for (label, host) in &targets {
16331        out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16332        // Test-NetConnection gives RTT; -InformationLevel Quiet just returns bool, so use ping
16333        let ps_ping = format!(
16334            r#"
16335$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
16336if ($r) {{
16337    $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
16338    $min  = ($rtts | Measure-Object -Minimum).Minimum
16339    $max  = ($rtts | Measure-Object -Maximum).Maximum
16340    $avg  = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
16341    $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
16342    "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
16343    "Packet loss: ${{loss}}%"
16344    "Sent: 4  Received: $($r.Count)"
16345}} else {{
16346    "UNREACHABLE — 100% packet loss"
16347}}
16348"#
16349        );
16350        match run_powershell(&ps_ping) {
16351            Ok(o) => {
16352                let body = o.trim().to_string();
16353                for line in body.lines() {
16354                    let l = line.trim();
16355                    if !l.is_empty() {
16356                        out.push_str(&format!("- {l}\n"));
16357                    }
16358                }
16359                if body.contains("UNREACHABLE") {
16360                    findings.push(format!(
16361                        "{label} ({host}) is unreachable — possible routing or firewall issue."
16362                    ));
16363                } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
16364                    let pct: u32 = loss_line
16365                        .chars()
16366                        .filter(|c| c.is_ascii_digit())
16367                        .collect::<String>()
16368                        .parse()
16369                        .unwrap_or(0);
16370                    if pct >= 25 {
16371                        findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
16372                    }
16373                    // High latency check
16374                    if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
16375                        // parse avg from "RTT min/avg/max: Xms / Yms / Zms"
16376                        let parts: Vec<&str> = rtt_line.split('/').collect();
16377                        if parts.len() >= 2 {
16378                            let avg_str: String =
16379                                parts[1].chars().filter(|c| c.is_ascii_digit()).collect();
16380                            let avg: u32 = avg_str.parse().unwrap_or(0);
16381                            if avg > 150 {
16382                                findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
16383                            }
16384                        }
16385                    }
16386                }
16387            }
16388            Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16389        }
16390    }
16391
16392    let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
16393    if findings.is_empty() {
16394        result.push_str("- Latency and reachability look normal.\n");
16395    } else {
16396        for f in &findings {
16397            result.push_str(&format!("- Finding: {f}\n"));
16398        }
16399    }
16400    result.push('\n');
16401    result.push_str(&out);
16402    Ok(result)
16403}
16404
16405#[cfg(not(windows))]
16406fn inspect_latency() -> Result<String, String> {
16407    let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
16408    let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
16409    let mut findings: Vec<String> = Vec::new();
16410
16411    for (label, host) in &targets {
16412        out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
16413        let ping = std::process::Command::new("ping")
16414            .args(["-c", "4", "-W", "2", host])
16415            .output();
16416        match ping {
16417            Ok(o) => {
16418                let body = String::from_utf8_lossy(&o.stdout).into_owned();
16419                for line in body.lines() {
16420                    let l = line.trim();
16421                    if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
16422                        out.push_str(&format!("- {l}\n"));
16423                    }
16424                }
16425                if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
16426                    findings.push(format!("{label} ({host}) is unreachable."));
16427                }
16428            }
16429            Err(e) => out.push_str(&format!("- ping error: {e}\n")),
16430        }
16431    }
16432
16433    if findings.is_empty() {
16434        out.insert_str(
16435            "Host inspection: latency\n\n=== Findings ===\n".len(),
16436            "- Latency and reachability look normal.\n",
16437        );
16438    } else {
16439        let mut prefix = String::new();
16440        for f in &findings {
16441            prefix.push_str(&format!("- Finding: {f}\n"));
16442        }
16443        out.insert_str(
16444            "Host inspection: latency\n\n=== Findings ===\n".len(),
16445            &prefix,
16446        );
16447    }
16448    Ok(out)
16449}
16450
16451#[cfg(windows)]
16452fn inspect_network_adapter() -> Result<String, String> {
16453    let mut out = String::new();
16454
16455    out.push_str("=== Network adapters ===\n");
16456    let ps_adapters = r#"
16457Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
16458    $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
16459    "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
16460}
16461"#;
16462    match run_powershell(ps_adapters) {
16463        Ok(o) => {
16464            for line in o.lines() {
16465                let l = line.trim();
16466                if !l.is_empty() {
16467                    out.push_str(&format!("- {l}\n"));
16468                }
16469            }
16470        }
16471        Err(e) => out.push_str(&format!("- Adapter query error: {e}\n")),
16472    }
16473
16474    out.push_str("\n=== Duplex and negotiated speed ===\n");
16475    let ps_duplex = r#"
16476Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16477    $name = $_.Name
16478    $duplex = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16479        Where-Object { $_.DisplayName -match "Duplex|Speed" } |
16480        Select-Object DisplayName, DisplayValue
16481    if ($duplex) {
16482        "--- $name ---"
16483        $duplex | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
16484    } else {
16485        "--- $name --- (no duplex/speed property exposed by driver)"
16486    }
16487}
16488"#;
16489    match run_powershell(ps_duplex) {
16490        Ok(o) => {
16491            let lines: Vec<&str> = o
16492                .lines()
16493                .map(|l| l.trim())
16494                .filter(|l| !l.is_empty())
16495                .collect();
16496            for l in &lines {
16497                out.push_str(&format!("- {l}\n"));
16498            }
16499        }
16500        Err(e) => out.push_str(&format!("- Duplex query error: {e}\n")),
16501    }
16502
16503    out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
16504    let ps_offload = r#"
16505Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16506    $name = $_.Name
16507    $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
16508        Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
16509        Select-Object DisplayName, DisplayValue
16510    if ($props) {
16511        "--- $name ---"
16512        $props | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
16513    }
16514}
16515"#;
16516    match run_powershell(ps_offload) {
16517        Ok(o) => {
16518            let lines: Vec<&str> = o
16519                .lines()
16520                .map(|l| l.trim())
16521                .filter(|l| !l.is_empty())
16522                .collect();
16523            if lines.is_empty() {
16524                out.push_str(
16525                    "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
16526                );
16527            } else {
16528                for l in &lines {
16529                    out.push_str(&format!("- {l}\n"));
16530                }
16531            }
16532        }
16533        Err(e) => out.push_str(&format!("- Offload query error: {e}\n")),
16534    }
16535
16536    out.push_str("\n=== Adapter error counters ===\n");
16537    let ps_errors = r#"
16538Get-NetAdapterStatistics | ForEach-Object {
16539    $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
16540    if ($errs -gt 0) {
16541        "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
16542    }
16543}
16544"#;
16545    match run_powershell(ps_errors) {
16546        Ok(o) => {
16547            let lines: Vec<&str> = o
16548                .lines()
16549                .map(|l| l.trim())
16550                .filter(|l| !l.is_empty())
16551                .collect();
16552            if lines.is_empty() {
16553                out.push_str("- No adapter errors or discards detected.\n");
16554            } else {
16555                for l in &lines {
16556                    out.push_str(&format!("- {l}\n"));
16557                }
16558            }
16559        }
16560        Err(e) => out.push_str(&format!("- Error counter query: {e}\n")),
16561    }
16562
16563    out.push_str("\n=== Wake-on-LAN and power settings ===\n");
16564    let ps_wol = r#"
16565Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
16566    $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
16567    if ($wol) {
16568        "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
16569    }
16570}
16571"#;
16572    match run_powershell(ps_wol) {
16573        Ok(o) => {
16574            let lines: Vec<&str> = o
16575                .lines()
16576                .map(|l| l.trim())
16577                .filter(|l| !l.is_empty())
16578                .collect();
16579            if lines.is_empty() {
16580                out.push_str("- Power management data unavailable for active adapters.\n");
16581            } else {
16582                for l in &lines {
16583                    out.push_str(&format!("- {l}\n"));
16584                }
16585            }
16586        }
16587        Err(e) => out.push_str(&format!("- WoL query error: {e}\n")),
16588    }
16589
16590    let mut findings: Vec<String> = Vec::new();
16591    // Check for error-prone adapters
16592    if out.contains("RX errors:") || out.contains("TX errors:") {
16593        findings
16594            .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
16595    }
16596    // Check for half-duplex (rare but still seen on older switches)
16597    if out.contains("Half") {
16598        findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
16599    }
16600
16601    let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
16602    if findings.is_empty() {
16603        result.push_str("- Network adapter configuration looks normal.\n");
16604    } else {
16605        for f in &findings {
16606            result.push_str(&format!("- Finding: {f}\n"));
16607        }
16608    }
16609    result.push('\n');
16610    result.push_str(&out);
16611    Ok(result)
16612}
16613
16614#[cfg(not(windows))]
16615fn inspect_network_adapter() -> Result<String, String> {
16616    let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
16617
16618    out.push_str("=== Network adapters (ip link) ===\n");
16619    let ip_link = std::process::Command::new("ip")
16620        .args(["link", "show"])
16621        .output();
16622    if let Ok(o) = ip_link {
16623        for line in String::from_utf8_lossy(&o.stdout).lines() {
16624            let l = line.trim();
16625            if !l.is_empty() {
16626                out.push_str(&format!("- {l}\n"));
16627            }
16628        }
16629    }
16630
16631    out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
16632    let ip_stats = std::process::Command::new("ip")
16633        .args(["-s", "link", "show"])
16634        .output();
16635    if let Ok(o) = ip_stats {
16636        for line in String::from_utf8_lossy(&o.stdout).lines() {
16637            let l = line.trim();
16638            if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
16639            {
16640                out.push_str(&format!("- {l}\n"));
16641            }
16642        }
16643    }
16644    Ok(out)
16645}
16646
16647#[cfg(windows)]
16648fn inspect_dhcp() -> Result<String, String> {
16649    let mut out = String::new();
16650
16651    out.push_str("=== DHCP lease details (per adapter) ===\n");
16652    let ps_dhcp = r#"
16653$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16654    Where-Object { $_.IPEnabled -eq $true }
16655foreach ($a in $adapters) {
16656    "--- $($a.Description) ---"
16657    "  DHCP Enabled:      $($a.DHCPEnabled)"
16658    if ($a.DHCPEnabled) {
16659        "  DHCP Server:       $($a.DHCPServer)"
16660        $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
16661        $expires  = $a.ConvertToDateTime($a.DHCPLeaseExpires)  2>$null
16662        "  Lease Obtained:    $obtained"
16663        "  Lease Expires:     $expires"
16664    }
16665    "  IP Address:        $($a.IPAddress -join ', ')"
16666    "  Subnet Mask:       $($a.IPSubnet -join ', ')"
16667    "  Default Gateway:   $($a.DefaultIPGateway -join ', ')"
16668    "  DNS Servers:       $($a.DNSServerSearchOrder -join ', ')"
16669    "  MAC Address:       $($a.MACAddress)"
16670    ""
16671}
16672"#;
16673    match run_powershell(ps_dhcp) {
16674        Ok(o) => {
16675            for line in o.lines() {
16676                let l = line.trim_end();
16677                if !l.is_empty() {
16678                    out.push_str(&format!("{l}\n"));
16679                }
16680            }
16681        }
16682        Err(e) => out.push_str(&format!("- DHCP query error: {e}\n")),
16683    }
16684
16685    // Findings: check for expired or very-soon-expiring leases
16686    let mut findings: Vec<String> = Vec::new();
16687    let ps_expiry = r#"
16688$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
16689foreach ($a in $adapters) {
16690    try {
16691        $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
16692        $now = Get-Date
16693        $hrs = ($exp - $now).TotalHours
16694        if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
16695        elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
16696    } catch {}
16697}
16698"#;
16699    if let Ok(o) = run_powershell(ps_expiry) {
16700        for line in o.lines() {
16701            let l = line.trim();
16702            if !l.is_empty() {
16703                if l.contains("EXPIRED") {
16704                    findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
16705                } else if l.contains("expires in") {
16706                    findings.push(format!("DHCP lease expiring soon — {l}"));
16707                }
16708            }
16709        }
16710    }
16711
16712    let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
16713    if findings.is_empty() {
16714        result.push_str("- DHCP leases look healthy.\n");
16715    } else {
16716        for f in &findings {
16717            result.push_str(&format!("- Finding: {f}\n"));
16718        }
16719    }
16720    result.push('\n');
16721    result.push_str(&out);
16722    Ok(result)
16723}
16724
16725#[cfg(not(windows))]
16726fn inspect_dhcp() -> Result<String, String> {
16727    let mut out = String::from(
16728        "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
16729    );
16730    out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
16731    for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
16732        if std::path::Path::new(path).exists() {
16733            let cat = std::process::Command::new("cat").arg(path).output();
16734            if let Ok(o) = cat {
16735                let text = String::from_utf8_lossy(&o.stdout);
16736                for line in text.lines().take(40) {
16737                    let l = line.trim();
16738                    if l.contains("lease")
16739                        || l.contains("expire")
16740                        || l.contains("server")
16741                        || l.contains("address")
16742                    {
16743                        out.push_str(&format!("- {l}\n"));
16744                    }
16745                }
16746            }
16747        }
16748    }
16749    // Also try ip addr for current IPs
16750    let ip = std::process::Command::new("ip")
16751        .args(["addr", "show"])
16752        .output();
16753    if let Ok(o) = ip {
16754        out.push_str("\n=== Current IP addresses (ip addr) ===\n");
16755        for line in String::from_utf8_lossy(&o.stdout).lines() {
16756            let l = line.trim();
16757            if l.starts_with("inet") || l.contains("dynamic") {
16758                out.push_str(&format!("- {l}\n"));
16759            }
16760        }
16761    }
16762    Ok(out)
16763}
16764
16765#[cfg(windows)]
16766fn inspect_mtu() -> Result<String, String> {
16767    let mut out = String::new();
16768
16769    out.push_str("=== Per-adapter MTU (IPv4) ===\n");
16770    let ps_mtu = r#"
16771Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
16772    Sort-Object ConnectionState, InterfaceAlias |
16773    ForEach-Object {
16774        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16775    }
16776"#;
16777    match run_powershell(ps_mtu) {
16778        Ok(o) => {
16779            for line in o.lines() {
16780                let l = line.trim();
16781                if !l.is_empty() {
16782                    out.push_str(&format!("- {l}\n"));
16783                }
16784            }
16785        }
16786        Err(e) => out.push_str(&format!("- MTU query error: {e}\n")),
16787    }
16788
16789    out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
16790    let ps_mtu6 = r#"
16791Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
16792    Sort-Object ConnectionState, InterfaceAlias |
16793    ForEach-Object {
16794        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16795    }
16796"#;
16797    match run_powershell(ps_mtu6) {
16798        Ok(o) => {
16799            for line in o.lines() {
16800                let l = line.trim();
16801                if !l.is_empty() {
16802                    out.push_str(&format!("- {l}\n"));
16803                }
16804            }
16805        }
16806        Err(e) => out.push_str(&format!("- IPv6 MTU query error: {e}\n")),
16807    }
16808
16809    out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
16810    // Send a 1472-byte payload (1500 - 28 IP+ICMP headers) to test standard Ethernet MTU
16811    let ps_pmtu = r#"
16812$sizes = @(1472, 1400, 1280, 576)
16813$result = $null
16814foreach ($s in $sizes) {
16815    $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
16816    if ($r) { $result = $s; break }
16817}
16818if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
16819else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
16820"#;
16821    match run_powershell(ps_pmtu) {
16822        Ok(o) => {
16823            for line in o.lines() {
16824                let l = line.trim();
16825                if !l.is_empty() {
16826                    out.push_str(&format!("- {l}\n"));
16827                }
16828            }
16829        }
16830        Err(e) => out.push_str(&format!("- Path MTU test error: {e}\n")),
16831    }
16832
16833    let mut findings: Vec<String> = Vec::new();
16834    if out.contains("MTU: 576 bytes") {
16835        findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
16836    }
16837    if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
16838        findings.push(
16839            "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
16840                .into(),
16841        );
16842    }
16843    if out.contains("All test sizes failed") {
16844        findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
16845    }
16846
16847    let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16848    if findings.is_empty() {
16849        result.push_str("- MTU configuration looks normal.\n");
16850    } else {
16851        for f in &findings {
16852            result.push_str(&format!("- Finding: {f}\n"));
16853        }
16854    }
16855    result.push('\n');
16856    result.push_str(&out);
16857    Ok(result)
16858}
16859
16860#[cfg(not(windows))]
16861fn inspect_mtu() -> Result<String, String> {
16862    let mut out = String::from(
16863        "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
16864    );
16865
16866    out.push_str("=== Per-interface MTU (ip link) ===\n");
16867    let ip = std::process::Command::new("ip")
16868        .args(["link", "show"])
16869        .output();
16870    if let Ok(o) = ip {
16871        for line in String::from_utf8_lossy(&o.stdout).lines() {
16872            let l = line.trim();
16873            if l.contains("mtu") || l.starts_with("\\d") {
16874                out.push_str(&format!("- {l}\n"));
16875            }
16876        }
16877    }
16878
16879    out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
16880    let ping = std::process::Command::new("ping")
16881        .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
16882        .output();
16883    match ping {
16884        Ok(o) => {
16885            let body = String::from_utf8_lossy(&o.stdout);
16886            for line in body.lines() {
16887                let l = line.trim();
16888                if !l.is_empty() {
16889                    out.push_str(&format!("- {l}\n"));
16890                }
16891            }
16892        }
16893        Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16894    }
16895    Ok(out)
16896}
16897
16898#[cfg(not(windows))]
16899fn inspect_cpu_power() -> Result<String, String> {
16900    let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
16901
16902    // Linux: cpufreq-info or /sys/devices/system/cpu
16903    out.push_str("=== CPU frequency (Linux) ===\n");
16904    let cat_scaling = std::process::Command::new("cat")
16905        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
16906        .output();
16907    if let Ok(o) = cat_scaling {
16908        let khz: u64 = String::from_utf8_lossy(&o.stdout)
16909            .trim()
16910            .parse()
16911            .unwrap_or(0);
16912        if khz > 0 {
16913            out.push_str(&format!("- Current: {} MHz\n", khz / 1000));
16914        }
16915    }
16916    let cat_max = std::process::Command::new("cat")
16917        .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
16918        .output();
16919    if let Ok(o) = cat_max {
16920        let khz: u64 = String::from_utf8_lossy(&o.stdout)
16921            .trim()
16922            .parse()
16923            .unwrap_or(0);
16924        if khz > 0 {
16925            out.push_str(&format!("- Max: {} MHz\n", khz / 1000));
16926        }
16927    }
16928    let governor = std::process::Command::new("cat")
16929        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
16930        .output();
16931    if let Ok(o) = governor {
16932        let g = String::from_utf8_lossy(&o.stdout);
16933        let g = g.trim();
16934        if !g.is_empty() {
16935            out.push_str(&format!("- Governor: {g}\n"));
16936        }
16937    }
16938    Ok(out)
16939}
16940
16941// ── IPv6 ────────────────────────────────────────────────────────────────────
16942
16943#[cfg(windows)]
16944fn inspect_ipv6() -> Result<String, String> {
16945    let script = r#"
16946$result = [System.Text.StringBuilder]::new()
16947
16948# Per-adapter IPv6 addresses
16949$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
16950$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16951    Where-Object { $_.IPAddress -notmatch '^::1$' } |
16952    Sort-Object InterfaceAlias
16953foreach ($a in $adapters) {
16954    $prefix = $a.PrefixOrigin
16955    $suffix = $a.SuffixOrigin
16956    $scope  = $a.AddressState
16957    $result.AppendLine("  [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength)  origin=$prefix/$suffix  state=$scope") | Out-Null
16958}
16959if (-not $adapters) { $result.AppendLine("  No global/link-local IPv6 addresses found.") | Out-Null }
16960
16961# Default gateway IPv6
16962$result.AppendLine("") | Out-Null
16963$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
16964$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
16965if ($gw6) {
16966    foreach ($g in $gw6) {
16967        $result.AppendLine("  [$($g.InterfaceAlias)] via $($g.NextHop)  metric=$($g.RouteMetric)") | Out-Null
16968    }
16969} else {
16970    $result.AppendLine("  No IPv6 default gateway configured.") | Out-Null
16971}
16972
16973# DHCPv6 lease info
16974$result.AppendLine("") | Out-Null
16975$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
16976$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16977    Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
16978if ($dhcpv6) {
16979    foreach ($d in $dhcpv6) {
16980        $result.AppendLine("  [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
16981    }
16982} else {
16983    $result.AppendLine("  No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
16984}
16985
16986# Privacy extensions
16987$result.AppendLine("") | Out-Null
16988$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
16989try {
16990    $priv = netsh interface ipv6 show privacy
16991    $result.AppendLine(($priv -join "`n")) | Out-Null
16992} catch {
16993    $result.AppendLine("  Could not retrieve privacy extension state.") | Out-Null
16994}
16995
16996# Tunnel adapters
16997$result.AppendLine("") | Out-Null
16998$result.AppendLine("=== Tunnel adapters ===") | Out-Null
16999$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
17000if ($tunnels) {
17001    foreach ($t in $tunnels) {
17002        $result.AppendLine("  $($t.Name): $($t.InterfaceDescription)  Status=$($t.Status)") | Out-Null
17003    }
17004} else {
17005    $result.AppendLine("  No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
17006}
17007
17008# Findings
17009$findings = [System.Collections.Generic.List[string]]::new()
17010$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17011    Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
17012if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
17013$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
17014if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
17015
17016$result.AppendLine("") | Out-Null
17017$result.AppendLine("=== Findings ===") | Out-Null
17018if ($findings.Count -eq 0) {
17019    $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
17020} else {
17021    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17022}
17023
17024Write-Output $result.ToString()
17025"#;
17026    let out = run_powershell(script)?;
17027    Ok(format!("Host inspection: ipv6\n\n{out}"))
17028}
17029
17030#[cfg(not(windows))]
17031fn inspect_ipv6() -> Result<String, String> {
17032    let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
17033    if let Ok(o) = std::process::Command::new("ip")
17034        .args(["-6", "addr", "show"])
17035        .output()
17036    {
17037        out.push_str(&String::from_utf8_lossy(&o.stdout));
17038    }
17039    out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
17040    if let Ok(o) = std::process::Command::new("ip")
17041        .args(["-6", "route"])
17042        .output()
17043    {
17044        out.push_str(&String::from_utf8_lossy(&o.stdout));
17045    }
17046    Ok(out)
17047}
17048
17049// ── TCP Parameters ──────────────────────────────────────────────────────────
17050
17051#[cfg(windows)]
17052fn inspect_tcp_params() -> Result<String, String> {
17053    let script = r#"
17054$result = [System.Text.StringBuilder]::new()
17055
17056# Autotuning and global TCP settings
17057$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
17058try {
17059    $global = netsh interface tcp show global
17060    foreach ($line in $global) {
17061        $l = $line.Trim()
17062        if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
17063            $result.AppendLine("  $l") | Out-Null
17064        }
17065    }
17066} catch {
17067    $result.AppendLine("  Could not retrieve TCP global settings.") | Out-Null
17068}
17069
17070# Supplemental params via Get-NetTCPSetting
17071$result.AppendLine("") | Out-Null
17072$result.AppendLine("=== TCP settings profiles ===") | Out-Null
17073try {
17074    $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
17075    foreach ($s in $tcpSettings) {
17076        $result.AppendLine("  Profile: $($s.SettingName)") | Out-Null
17077        $result.AppendLine("    CongestionProvider:      $($s.CongestionProvider)") | Out-Null
17078        $result.AppendLine("    InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
17079        $result.AppendLine("    AutoTuningLevelLocal:    $($s.AutoTuningLevelLocal)") | Out-Null
17080        $result.AppendLine("    ScalingHeuristics:       $($s.ScalingHeuristics)") | Out-Null
17081        $result.AppendLine("    DynamicPortRangeStart:   $($s.DynamicPortRangeStartPort)") | Out-Null
17082        $result.AppendLine("    DynamicPortRangeEnd:     $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
17083        $result.AppendLine("") | Out-Null
17084    }
17085} catch {
17086    $result.AppendLine("  Get-NetTCPSetting unavailable.") | Out-Null
17087}
17088
17089# Chimney offload state
17090$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
17091try {
17092    $chimney = netsh interface tcp show chimney
17093    $result.AppendLine(($chimney -join "`n  ")) | Out-Null
17094} catch {
17095    $result.AppendLine("  Could not retrieve chimney state.") | Out-Null
17096}
17097
17098# ECN state
17099$result.AppendLine("") | Out-Null
17100$result.AppendLine("=== ECN capability ===") | Out-Null
17101try {
17102    $ecn = netsh interface tcp show ecncapability
17103    $result.AppendLine(($ecn -join "`n  ")) | Out-Null
17104} catch {
17105    $result.AppendLine("  Could not retrieve ECN state.") | Out-Null
17106}
17107
17108# Findings
17109$findings = [System.Collections.Generic.List[string]]::new()
17110try {
17111    $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
17112    if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
17113        $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
17114    }
17115    if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
17116        $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
17117    }
17118} catch {}
17119
17120$result.AppendLine("") | Out-Null
17121$result.AppendLine("=== Findings ===") | Out-Null
17122if ($findings.Count -eq 0) {
17123    $result.AppendLine("- TCP parameters look normal.") | Out-Null
17124} else {
17125    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17126}
17127
17128Write-Output $result.ToString()
17129"#;
17130    let out = run_powershell(script)?;
17131    Ok(format!("Host inspection: tcp_params\n\n{out}"))
17132}
17133
17134#[cfg(not(windows))]
17135fn inspect_tcp_params() -> Result<String, String> {
17136    let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
17137    for key in &[
17138        "net.ipv4.tcp_congestion_control",
17139        "net.ipv4.tcp_rmem",
17140        "net.ipv4.tcp_wmem",
17141        "net.ipv4.tcp_window_scaling",
17142        "net.ipv4.tcp_ecn",
17143        "net.ipv4.tcp_timestamps",
17144    ] {
17145        if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
17146            out.push_str(&format!(
17147                "  {}\n",
17148                String::from_utf8_lossy(&o.stdout).trim()
17149            ));
17150        }
17151    }
17152    Ok(out)
17153}
17154
17155// ── WLAN Profiles ───────────────────────────────────────────────────────────
17156
17157#[cfg(windows)]
17158fn inspect_wlan_profiles() -> Result<String, String> {
17159    let script = r#"
17160$result = [System.Text.StringBuilder]::new()
17161
17162# List all saved profiles
17163$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
17164try {
17165    $profilesRaw = netsh wlan show profiles
17166    $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17167        $_.Matches[0].Groups[1].Value.Trim()
17168    }
17169
17170    if (-not $profiles) {
17171        $result.AppendLine("  No saved wireless profiles found.") | Out-Null
17172    } else {
17173        foreach ($p in $profiles) {
17174            $result.AppendLine("") | Out-Null
17175            $result.AppendLine("  Profile: $p") | Out-Null
17176            # Get detail for each profile
17177            $detail = netsh wlan show profile name="$p" key=clear 2>$null
17178            $auth      = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17179            $cipher    = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
17180            $conn      = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
17181            $autoConn  = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
17182            if ($auth)     { $result.AppendLine("    Authentication:    $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17183            if ($cipher)   { $result.AppendLine("    Cipher:            $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17184            if ($conn)     { $result.AppendLine("    Connection mode:   $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17185            if ($autoConn) { $result.AppendLine("    Auto-connect:      $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17186        }
17187    }
17188} catch {
17189    $result.AppendLine("  netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
17190}
17191
17192# Currently connected SSID
17193$result.AppendLine("") | Out-Null
17194$result.AppendLine("=== Currently connected ===") | Out-Null
17195try {
17196    $conn = netsh wlan show interfaces
17197    $ssid   = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
17198    $bssid  = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
17199    $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
17200    $radio  = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
17201    if ($ssid)   { $result.AppendLine("  SSID:       $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17202    if ($bssid)  { $result.AppendLine("  BSSID:      $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17203    if ($signal) { $result.AppendLine("  Signal:     $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17204    if ($radio)  { $result.AppendLine("  Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17205    if (-not $ssid) { $result.AppendLine("  Not connected to any wireless network.") | Out-Null }
17206} catch {
17207    $result.AppendLine("  Could not query wireless interface state.") | Out-Null
17208}
17209
17210# Findings
17211$findings = [System.Collections.Generic.List[string]]::new()
17212try {
17213    $allDetail = netsh wlan show profiles 2>$null
17214    $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17215        $_.Matches[0].Groups[1].Value.Trim()
17216    }
17217    foreach ($pn in $profileNames) {
17218        $det = netsh wlan show profile name="$pn" key=clear 2>$null
17219        $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17220        if ($authLine) {
17221            $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
17222            if ($authVal -match 'Open|WEP|None') {
17223                $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
17224            }
17225        }
17226    }
17227} catch {}
17228
17229$result.AppendLine("") | Out-Null
17230$result.AppendLine("=== Findings ===") | Out-Null
17231if ($findings.Count -eq 0) {
17232    $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
17233} else {
17234    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17235}
17236
17237Write-Output $result.ToString()
17238"#;
17239    let out = run_powershell(script)?;
17240    Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
17241}
17242
17243#[cfg(not(windows))]
17244fn inspect_wlan_profiles() -> Result<String, String> {
17245    let mut out =
17246        String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
17247    // Try nmcli (NetworkManager)
17248    if let Ok(o) = std::process::Command::new("nmcli")
17249        .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
17250        .output()
17251    {
17252        for line in String::from_utf8_lossy(&o.stdout).lines() {
17253            if line.contains("wireless") || line.contains("wifi") {
17254                out.push_str(&format!("  {line}\n"));
17255            }
17256        }
17257    } else {
17258        out.push_str("  nmcli not available.\n");
17259    }
17260    Ok(out)
17261}
17262
17263// ── IPSec ───────────────────────────────────────────────────────────────────
17264
17265#[cfg(windows)]
17266fn inspect_ipsec() -> Result<String, String> {
17267    let script = r#"
17268$result = [System.Text.StringBuilder]::new()
17269
17270# IPSec rules (firewall-integrated)
17271$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
17272try {
17273    $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
17274    if ($rules) {
17275        foreach ($r in $rules) {
17276            $result.AppendLine("  [$($r.DisplayName)]") | Out-Null
17277            $result.AppendLine("    Mode:       $($r.Mode)") | Out-Null
17278            $result.AppendLine("    Action:     $($r.Action)") | Out-Null
17279            $result.AppendLine("    InProfile:  $($r.Profile)") | Out-Null
17280        }
17281    } else {
17282        $result.AppendLine("  No enabled IPSec connection security rules found.") | Out-Null
17283    }
17284} catch {
17285    $result.AppendLine("  Get-NetIPsecRule unavailable.") | Out-Null
17286}
17287
17288# Active main-mode SAs
17289$result.AppendLine("") | Out-Null
17290$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
17291try {
17292    $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
17293    if ($mmSAs) {
17294        foreach ($sa in $mmSAs) {
17295            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
17296            $result.AppendLine("    AuthMethod: $($sa.LocalFirstId)  Cipher: $($sa.Cipher)") | Out-Null
17297        }
17298    } else {
17299        $result.AppendLine("  No active main-mode IPSec SAs.") | Out-Null
17300    }
17301} catch {
17302    $result.AppendLine("  Get-NetIPsecMainModeSA unavailable.") | Out-Null
17303}
17304
17305# Active quick-mode SAs
17306$result.AppendLine("") | Out-Null
17307$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
17308try {
17309    $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
17310    if ($qmSAs) {
17311        foreach ($sa in $qmSAs) {
17312            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
17313            $result.AppendLine("    Encapsulation: $($sa.EncapsulationMode)  Protocol: $($sa.TransportLayerProtocol)") | Out-Null
17314        }
17315    } else {
17316        $result.AppendLine("  No active quick-mode IPSec SAs.") | Out-Null
17317    }
17318} catch {
17319    $result.AppendLine("  Get-NetIPsecQuickModeSA unavailable.") | Out-Null
17320}
17321
17322# IKE service state
17323$result.AppendLine("") | Out-Null
17324$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
17325$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
17326if ($ikeAgentSvc) {
17327    $result.AppendLine("  PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
17328} else {
17329    $result.AppendLine("  PolicyAgent service not found.") | Out-Null
17330}
17331
17332# Findings
17333$findings = [System.Collections.Generic.List[string]]::new()
17334$mmSACount = 0
17335try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
17336if ($mmSACount -gt 0) {
17337    $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
17338}
17339
17340$result.AppendLine("") | Out-Null
17341$result.AppendLine("=== Findings ===") | Out-Null
17342if ($findings.Count -eq 0) {
17343    $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
17344} else {
17345    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17346}
17347
17348Write-Output $result.ToString()
17349"#;
17350    let out = run_powershell(script)?;
17351    Ok(format!("Host inspection: ipsec\n\n{out}"))
17352}
17353
17354#[cfg(not(windows))]
17355fn inspect_ipsec() -> Result<String, String> {
17356    let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
17357    if let Ok(o) = std::process::Command::new("ip")
17358        .args(["xfrm", "state"])
17359        .output()
17360    {
17361        let body = String::from_utf8_lossy(&o.stdout);
17362        if body.trim().is_empty() {
17363            out.push_str("  No active IPSec SAs.\n");
17364        } else {
17365            out.push_str(&body);
17366        }
17367    }
17368    out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
17369    if let Ok(o) = std::process::Command::new("ip")
17370        .args(["xfrm", "policy"])
17371        .output()
17372    {
17373        let body = String::from_utf8_lossy(&o.stdout);
17374        if body.trim().is_empty() {
17375            out.push_str("  No IPSec policies.\n");
17376        } else {
17377            out.push_str(&body);
17378        }
17379    }
17380    Ok(out)
17381}
17382
17383// ── NetBIOS ──────────────────────────────────────────────────────────────────
17384
17385#[cfg(windows)]
17386fn inspect_netbios() -> Result<String, String> {
17387    let script = r#"
17388$result = [System.Text.StringBuilder]::new()
17389
17390# NetBIOS node type and WINS per adapter
17391$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
17392try {
17393    $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17394        Where-Object { $_.IPEnabled -eq $true }
17395    foreach ($a in $adapters) {
17396        $nodeType = switch ($a.TcpipNetbiosOptions) {
17397            0 { "EnableNetBIOSViaDHCP" }
17398            1 { "Enabled" }
17399            2 { "Disabled" }
17400            default { "Unknown ($($a.TcpipNetbiosOptions))" }
17401        }
17402        $result.AppendLine("  [$($a.Description)]") | Out-Null
17403        $result.AppendLine("    NetBIOS over TCP/IP: $nodeType") | Out-Null
17404        if ($a.WINSPrimaryServer) {
17405            $result.AppendLine("    WINS Primary:        $($a.WINSPrimaryServer)") | Out-Null
17406        }
17407        if ($a.WINSSecondaryServer) {
17408            $result.AppendLine("    WINS Secondary:      $($a.WINSSecondaryServer)") | Out-Null
17409        }
17410    }
17411} catch {
17412    $result.AppendLine("  Could not query NetBIOS adapter config.") | Out-Null
17413}
17414
17415# nbtstat -n — registered local NetBIOS names
17416$result.AppendLine("") | Out-Null
17417$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
17418try {
17419    $nbt = nbtstat -n 2>$null
17420    foreach ($line in $nbt) {
17421        $l = $line.Trim()
17422        if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
17423            $result.AppendLine("  $l") | Out-Null
17424        }
17425    }
17426} catch {
17427    $result.AppendLine("  nbtstat not available.") | Out-Null
17428}
17429
17430# NetBIOS session table
17431$result.AppendLine("") | Out-Null
17432$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
17433try {
17434    $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
17435    if ($sessions) {
17436        foreach ($s in $sessions) { $result.AppendLine("  $($s.Trim())") | Out-Null }
17437    } else {
17438        $result.AppendLine("  No active NetBIOS sessions.") | Out-Null
17439    }
17440} catch {
17441    $result.AppendLine("  Could not query NetBIOS sessions.") | Out-Null
17442}
17443
17444# Findings
17445$findings = [System.Collections.Generic.List[string]]::new()
17446try {
17447    $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17448        Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
17449    if ($enabled) {
17450        $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
17451    }
17452    $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17453        Where-Object { $_.WINSPrimaryServer }
17454    if ($wins) {
17455        $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
17456    }
17457} catch {}
17458
17459$result.AppendLine("") | Out-Null
17460$result.AppendLine("=== Findings ===") | Out-Null
17461if ($findings.Count -eq 0) {
17462    $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
17463} else {
17464    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17465}
17466
17467Write-Output $result.ToString()
17468"#;
17469    let out = run_powershell(script)?;
17470    Ok(format!("Host inspection: netbios\n\n{out}"))
17471}
17472
17473#[cfg(not(windows))]
17474fn inspect_netbios() -> Result<String, String> {
17475    let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
17476    if let Ok(o) = std::process::Command::new("nmblookup")
17477        .arg("-A")
17478        .arg("localhost")
17479        .output()
17480    {
17481        out.push_str(&String::from_utf8_lossy(&o.stdout));
17482    } else {
17483        out.push_str("  nmblookup not available (Samba not installed).\n");
17484    }
17485    Ok(out)
17486}
17487
17488// ── NIC Teaming ──────────────────────────────────────────────────────────────
17489
17490#[cfg(windows)]
17491fn inspect_nic_teaming() -> Result<String, String> {
17492    let script = r#"
17493$result = [System.Text.StringBuilder]::new()
17494
17495# Team inventory
17496$result.AppendLine("=== NIC teams ===") | Out-Null
17497try {
17498    $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
17499    if ($teams) {
17500        foreach ($t in $teams) {
17501            $result.AppendLine("  Team: $($t.Name)") | Out-Null
17502            $result.AppendLine("    Mode:            $($t.TeamingMode)") | Out-Null
17503            $result.AppendLine("    LB Algorithm:    $($t.LoadBalancingAlgorithm)") | Out-Null
17504            $result.AppendLine("    Status:          $($t.Status)") | Out-Null
17505            $result.AppendLine("    Members:         $($t.Members -join ', ')") | Out-Null
17506            $result.AppendLine("    VLANs:           $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
17507        }
17508    } else {
17509        $result.AppendLine("  No NIC teams configured on this machine.") | Out-Null
17510    }
17511} catch {
17512    $result.AppendLine("  Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
17513}
17514
17515# Team members detail
17516$result.AppendLine("") | Out-Null
17517$result.AppendLine("=== Team member detail ===") | Out-Null
17518try {
17519    $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
17520    if ($members) {
17521        foreach ($m in $members) {
17522            $result.AppendLine("  [$($m.Team)] $($m.Name)  Role=$($m.AdministrativeMode)  Status=$($m.OperationalStatus)") | Out-Null
17523        }
17524    } else {
17525        $result.AppendLine("  No team members found.") | Out-Null
17526    }
17527} catch {
17528    $result.AppendLine("  Could not query team members.") | Out-Null
17529}
17530
17531# Findings
17532$findings = [System.Collections.Generic.List[string]]::new()
17533try {
17534    $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
17535    if ($degraded) {
17536        foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
17537    }
17538    $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
17539    if ($downMembers) {
17540        foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
17541    }
17542} catch {}
17543
17544$result.AppendLine("") | Out-Null
17545$result.AppendLine("=== Findings ===") | Out-Null
17546if ($findings.Count -eq 0) {
17547    $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
17548} else {
17549    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17550}
17551
17552Write-Output $result.ToString()
17553"#;
17554    let out = run_powershell(script)?;
17555    Ok(format!("Host inspection: nic_teaming\n\n{out}"))
17556}
17557
17558#[cfg(not(windows))]
17559fn inspect_nic_teaming() -> Result<String, String> {
17560    let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
17561    if let Ok(o) = std::process::Command::new("cat")
17562        .arg("/proc/net/bonding/bond0")
17563        .output()
17564    {
17565        if o.status.success() {
17566            out.push_str(&String::from_utf8_lossy(&o.stdout));
17567        } else {
17568            out.push_str("  No bond0 interface found.\n");
17569        }
17570    }
17571    if let Ok(o) = std::process::Command::new("ip")
17572        .args(["link", "show", "type", "bond"])
17573        .output()
17574    {
17575        let body = String::from_utf8_lossy(&o.stdout);
17576        if !body.trim().is_empty() {
17577            out.push_str("\n=== Bond links (ip link) ===\n");
17578            out.push_str(&body);
17579        }
17580    }
17581    Ok(out)
17582}
17583
17584// ── SNMP ─────────────────────────────────────────────────────────────────────
17585
17586#[cfg(windows)]
17587fn inspect_snmp() -> Result<String, String> {
17588    let script = r#"
17589$result = [System.Text.StringBuilder]::new()
17590
17591# SNMP service state
17592$result.AppendLine("=== SNMP service state ===") | Out-Null
17593$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17594if ($svc) {
17595    $result.AppendLine("  SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
17596} else {
17597    $result.AppendLine("  SNMP Agent service not installed.") | Out-Null
17598}
17599
17600$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
17601if ($svcTrap) {
17602    $result.AppendLine("  SNMP Trap service:  $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
17603}
17604
17605# Community strings (presence only — values redacted)
17606$result.AppendLine("") | Out-Null
17607$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
17608try {
17609    $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17610    if ($communities) {
17611        $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
17612        if ($names) {
17613            foreach ($n in $names) {
17614                $result.AppendLine("  Community: '$n'  (value redacted)") | Out-Null
17615            }
17616        } else {
17617            $result.AppendLine("  No community strings configured.") | Out-Null
17618        }
17619    } else {
17620        $result.AppendLine("  Registry key not found (SNMP may not be configured).") | Out-Null
17621    }
17622} catch {
17623    $result.AppendLine("  Could not read community strings (SNMP not configured or access denied).") | Out-Null
17624}
17625
17626# Permitted managers
17627$result.AppendLine("") | Out-Null
17628$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
17629try {
17630    $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
17631    if ($managers) {
17632        $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
17633        if ($mgrs) {
17634            foreach ($m in $mgrs) { $result.AppendLine("  $m") | Out-Null }
17635        } else {
17636            $result.AppendLine("  No permitted managers configured (accepts from any host).") | Out-Null
17637        }
17638    } else {
17639        $result.AppendLine("  No manager restrictions configured.") | Out-Null
17640    }
17641} catch {
17642    $result.AppendLine("  Could not read permitted managers.") | Out-Null
17643}
17644
17645# Findings
17646$findings = [System.Collections.Generic.List[string]]::new()
17647$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
17648if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
17649    $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
17650    try {
17651        $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
17652        $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
17653        if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
17654    } catch {}
17655}
17656
17657$result.AppendLine("") | Out-Null
17658$result.AppendLine("=== Findings ===") | Out-Null
17659if ($findings.Count -eq 0) {
17660    $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
17661} else {
17662    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17663}
17664
17665Write-Output $result.ToString()
17666"#;
17667    let out = run_powershell(script)?;
17668    Ok(format!("Host inspection: snmp\n\n{out}"))
17669}
17670
17671#[cfg(not(windows))]
17672fn inspect_snmp() -> Result<String, String> {
17673    let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
17674    for svc in &["snmpd", "snmp"] {
17675        if let Ok(o) = std::process::Command::new("systemctl")
17676            .args(["is-active", svc])
17677            .output()
17678        {
17679            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
17680            out.push_str(&format!("  {svc}: {status}\n"));
17681        }
17682    }
17683    out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
17684    if let Ok(o) = std::process::Command::new("grep")
17685        .args(["-i", "community", "/etc/snmp/snmpd.conf"])
17686        .output()
17687    {
17688        if o.status.success() {
17689            for line in String::from_utf8_lossy(&o.stdout).lines() {
17690                out.push_str(&format!("  {line}\n"));
17691            }
17692        } else {
17693            out.push_str("  /etc/snmp/snmpd.conf not found or no community lines.\n");
17694        }
17695    }
17696    Ok(out)
17697}
17698
17699// ── Port Test ─────────────────────────────────────────────────────────────────
17700
17701#[cfg(windows)]
17702fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17703    let target_host = host.unwrap_or("8.8.8.8");
17704    let target_port = port.unwrap_or(443);
17705
17706    let script = format!(
17707        r#"
17708$result = [System.Text.StringBuilder]::new()
17709$result.AppendLine("=== Port reachability test ===") | Out-Null
17710$result.AppendLine("  Target: {target_host}:{target_port}") | Out-Null
17711$result.AppendLine("") | Out-Null
17712
17713try {{
17714    $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
17715    if ($test) {{
17716        $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
17717        $result.AppendLine("  Result:          $status") | Out-Null
17718        $result.AppendLine("  Remote address:  $($test.RemoteAddress)") | Out-Null
17719        $result.AppendLine("  Remote port:     $($test.RemotePort)") | Out-Null
17720        if ($test.PingSucceeded) {{
17721            $result.AppendLine("  ICMP ping:       Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
17722        }} else {{
17723            $result.AppendLine("  ICMP ping:       Failed (host may block ICMP)") | Out-Null
17724        }}
17725        $result.AppendLine("  Interface used:  $($test.InterfaceAlias)") | Out-Null
17726        $result.AppendLine("  Source address:  $($test.SourceAddress.IPAddress)") | Out-Null
17727
17728        $result.AppendLine("") | Out-Null
17729        $result.AppendLine("=== Findings ===") | Out-Null
17730        if ($test.TcpTestSucceeded) {{
17731            $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
17732        }} else {{
17733            $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
17734            $result.AppendLine("  Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
17735        }}
17736    }}
17737}} catch {{
17738    $result.AppendLine("  Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
17739}}
17740
17741Write-Output $result.ToString()
17742"#
17743    );
17744    let out = run_powershell(&script)?;
17745    Ok(format!("Host inspection: port_test\n\n{out}"))
17746}
17747
17748#[cfg(not(windows))]
17749fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17750    let target_host = host.unwrap_or("8.8.8.8");
17751    let target_port = port.unwrap_or(443);
17752    let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n  Target: {target_host}:{target_port}\n\n");
17753    // nc -zv with timeout
17754    let nc = std::process::Command::new("nc")
17755        .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
17756        .output();
17757    match nc {
17758        Ok(o) => {
17759            let stderr = String::from_utf8_lossy(&o.stderr);
17760            let stdout = String::from_utf8_lossy(&o.stdout);
17761            let body = if !stdout.trim().is_empty() {
17762                stdout.as_ref()
17763            } else {
17764                stderr.as_ref()
17765            };
17766            out.push_str(&format!("  {}\n", body.trim()));
17767            out.push_str("\n=== Findings ===\n");
17768            if o.status.success() {
17769                out.push_str(&format!("- Port {target_port} on {target_host} is OPEN.\n"));
17770            } else {
17771                out.push_str(&format!(
17772                    "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
17773                ));
17774            }
17775        }
17776        Err(e) => out.push_str(&format!("  nc not available: {e}\n")),
17777    }
17778    Ok(out)
17779}
17780
17781// ── Network Profile ───────────────────────────────────────────────────────────
17782
17783#[cfg(windows)]
17784fn inspect_network_profile() -> Result<String, String> {
17785    let script = r#"
17786$result = [System.Text.StringBuilder]::new()
17787
17788$result.AppendLine("=== Network location profiles ===") | Out-Null
17789try {
17790    $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
17791    if ($profiles) {
17792        foreach ($p in $profiles) {
17793            $result.AppendLine("  Interface: $($p.InterfaceAlias)") | Out-Null
17794            $result.AppendLine("    Network name:    $($p.Name)") | Out-Null
17795            $result.AppendLine("    Category:        $($p.NetworkCategory)") | Out-Null
17796            $result.AppendLine("    IPv4 conn:       $($p.IPv4Connectivity)") | Out-Null
17797            $result.AppendLine("    IPv6 conn:       $($p.IPv6Connectivity)") | Out-Null
17798            $result.AppendLine("") | Out-Null
17799        }
17800    } else {
17801        $result.AppendLine("  No network connection profiles found.") | Out-Null
17802    }
17803} catch {
17804    $result.AppendLine("  Could not query network profiles.") | Out-Null
17805}
17806
17807# Findings
17808$findings = [System.Collections.Generic.List[string]]::new()
17809try {
17810    $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
17811    if ($pub) {
17812        foreach ($p in $pub) {
17813            $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
17814        }
17815    }
17816    $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
17817    if ($domain) {
17818        foreach ($d in $domain) {
17819            $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
17820        }
17821    }
17822} catch {}
17823
17824$result.AppendLine("=== Findings ===") | Out-Null
17825if ($findings.Count -eq 0) {
17826    $result.AppendLine("- Network profiles look normal.") | Out-Null
17827} else {
17828    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17829}
17830
17831Write-Output $result.ToString()
17832"#;
17833    let out = run_powershell(script)?;
17834    Ok(format!("Host inspection: network_profile\n\n{out}"))
17835}
17836
17837#[cfg(not(windows))]
17838fn inspect_network_profile() -> Result<String, String> {
17839    let mut out = String::from(
17840        "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
17841    );
17842    if let Ok(o) = std::process::Command::new("nmcli")
17843        .args([
17844            "-t",
17845            "-f",
17846            "NAME,TYPE,STATE,DEVICE",
17847            "connection",
17848            "show",
17849            "--active",
17850        ])
17851        .output()
17852    {
17853        out.push_str(&String::from_utf8_lossy(&o.stdout));
17854    } else {
17855        out.push_str("  nmcli not available.\n");
17856    }
17857    Ok(out)
17858}
17859
17860// ── Storage Spaces ────────────────────────────────────────────────────────────
17861
17862#[cfg(windows)]
17863fn inspect_storage_spaces() -> Result<String, String> {
17864    let script = r#"
17865$result = [System.Text.StringBuilder]::new()
17866
17867# Storage Pools
17868try {
17869    $pools = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue
17870    if ($pools) {
17871        $result.AppendLine("=== Storage Pools ===") | Out-Null
17872        foreach ($pool in $pools) {
17873            $health = $pool.HealthStatus
17874            $oper   = $pool.OperationalStatus
17875            $sizGB  = [math]::Round($pool.Size / 1GB, 1)
17876            $allocGB= [math]::Round($pool.AllocatedSize / 1GB, 1)
17877            $result.AppendLine("  Pool: $($pool.FriendlyName)  Size: ${sizGB}GB  Allocated: ${allocGB}GB  Health: $health  Status: $oper") | Out-Null
17878        }
17879        $result.AppendLine("") | Out-Null
17880    } else {
17881        $result.AppendLine("=== Storage Pools ===") | Out-Null
17882        $result.AppendLine("  No Storage Spaces pools configured.") | Out-Null
17883        $result.AppendLine("") | Out-Null
17884    }
17885} catch {
17886    $result.AppendLine("=== Storage Pools ===") | Out-Null
17887    $result.AppendLine("  Unable to query storage pools (may require elevation).") | Out-Null
17888    $result.AppendLine("") | Out-Null
17889}
17890
17891# Virtual Disks
17892try {
17893    $vdisks = Get-VirtualDisk -ErrorAction SilentlyContinue
17894    if ($vdisks) {
17895        $result.AppendLine("=== Virtual Disks ===") | Out-Null
17896        foreach ($vd in $vdisks) {
17897            $health  = $vd.HealthStatus
17898            $oper    = $vd.OperationalStatus
17899            $layout  = $vd.ResiliencySettingName
17900            $sizGB   = [math]::Round($vd.Size / 1GB, 1)
17901            $result.AppendLine("  VDisk: $($vd.FriendlyName)  Layout: $layout  Size: ${sizGB}GB  Health: $health  Status: $oper") | Out-Null
17902        }
17903        $result.AppendLine("") | Out-Null
17904    } else {
17905        $result.AppendLine("=== Virtual Disks ===") | Out-Null
17906        $result.AppendLine("  No Storage Spaces virtual disks configured.") | Out-Null
17907        $result.AppendLine("") | Out-Null
17908    }
17909} catch {
17910    $result.AppendLine("=== Virtual Disks ===") | Out-Null
17911    $result.AppendLine("  Unable to query virtual disks.") | Out-Null
17912    $result.AppendLine("") | Out-Null
17913}
17914
17915# Physical Disks in pools
17916try {
17917    $pdisks = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.Usage -ne 'Journal' }
17918    if ($pdisks) {
17919        $result.AppendLine("=== Physical Disks ===") | Out-Null
17920        foreach ($pd in $pdisks) {
17921            $sizGB  = [math]::Round($pd.Size / 1GB, 1)
17922            $health = $pd.HealthStatus
17923            $usage  = $pd.Usage
17924            $media  = $pd.MediaType
17925            $result.AppendLine("  $($pd.FriendlyName)  ${sizGB}GB  $media  Usage: $usage  Health: $health") | Out-Null
17926        }
17927        $result.AppendLine("") | Out-Null
17928    }
17929} catch {}
17930
17931# Findings
17932$findings = @()
17933try {
17934    $unhealthy = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17935    foreach ($p in $unhealthy) { $findings += "WARN: Pool '$($p.FriendlyName)' health is $($p.HealthStatus)" }
17936    $degraded = Get-VirtualDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
17937    foreach ($v in $degraded) { $findings += "WARN: VDisk '$($v.FriendlyName)' health is $($v.HealthStatus) — check for failed drives" }
17938    $failedPd = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -eq 'Unhealthy' -or $_.OperationalStatus -eq 'Lost Communication' }
17939    foreach ($d in $failedPd) { $findings += "CRITICAL: Physical disk '$($d.FriendlyName)' is $($d.HealthStatus) / $($d.OperationalStatus)" }
17940} catch {}
17941
17942if ($findings.Count -gt 0) {
17943    $result.AppendLine("=== Findings ===") | Out-Null
17944    foreach ($f in $findings) { $result.AppendLine("  $f") | Out-Null }
17945} else {
17946    $result.AppendLine("=== Findings ===") | Out-Null
17947    $result.AppendLine("  All storage pool components healthy (or no Storage Spaces configured).") | Out-Null
17948}
17949
17950Write-Output $result.ToString().TrimEnd()
17951"#;
17952    let out = run_powershell(script)?;
17953    Ok(format!("Host inspection: storage_spaces\n\n{out}"))
17954}
17955
17956#[cfg(not(windows))]
17957fn inspect_storage_spaces() -> Result<String, String> {
17958    let mut out = String::from("Host inspection: storage_spaces\n\n");
17959    // Linux: check mdadm software RAID
17960    let mdstat = std::fs::read_to_string("/proc/mdstat").unwrap_or_default();
17961    if !mdstat.is_empty() {
17962        out.push_str("=== Software RAID (/proc/mdstat) ===\n");
17963        out.push_str(&mdstat);
17964    } else {
17965        out.push_str("No mdadm software RAID detected (/proc/mdstat not found or empty).\n");
17966    }
17967    // Check LVM
17968    if let Ok(o) = Command::new("lvs")
17969        .args(["--noheadings", "-o", "lv_name,vg_name,lv_size,lv_attr"])
17970        .output()
17971    {
17972        let lvs = String::from_utf8_lossy(&o.stdout).to_string();
17973        if !lvs.trim().is_empty() {
17974            out.push_str("\n=== LVM Logical Volumes ===\n");
17975            out.push_str(&lvs);
17976        }
17977    }
17978    Ok(out)
17979}
17980
17981// ── Defender Quarantine / Threat History ─────────────────────────────────────
17982
17983#[cfg(windows)]
17984fn inspect_defender_quarantine(max_entries: usize) -> Result<String, String> {
17985    let limit = max_entries.min(50);
17986    let script = format!(
17987        r#"
17988$result = [System.Text.StringBuilder]::new()
17989
17990# Current threat detections (active + quarantined)
17991try {{
17992    $threats = Get-MpThreatDetection -ErrorAction SilentlyContinue | Sort-Object InitialDetectionTime -Descending | Select-Object -First {limit}
17993    if ($threats) {{
17994        $result.AppendLine("=== Recent Threat Detections (last {limit}) ===") | Out-Null
17995        foreach ($t in $threats) {{
17996            $name    = (Get-MpThreat -ThreatID $t.ThreatID -ErrorAction SilentlyContinue).ThreatName
17997            if (-not $name) {{ $name = "ID:$($t.ThreatID)" }}
17998            $time    = $t.InitialDetectionTime.ToString("yyyy-MM-dd HH:mm")
17999            $action  = $t.ActionSuccess
18000            $status  = $t.CurrentThreatExecutionStatusID
18001            $result.AppendLine("  [$time] $name  ActionSuccess:$action  Status:$status") | Out-Null
18002        }}
18003        $result.AppendLine("") | Out-Null
18004    }} else {{
18005        $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18006        $result.AppendLine("  No threat detections on record — Defender history is clean.") | Out-Null
18007        $result.AppendLine("") | Out-Null
18008    }}
18009}} catch {{
18010    $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18011    $result.AppendLine("  Unable to query threat detections: $_") | Out-Null
18012    $result.AppendLine("") | Out-Null
18013}}
18014
18015# Quarantine items
18016try {{
18017    $quarantine = Get-MpThreat -ErrorAction SilentlyContinue | Where-Object {{ $_.IsActive -eq $false }} | Select-Object -First {limit}
18018    if ($quarantine) {{
18019        $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18020        foreach ($q in $quarantine) {{
18021            $result.AppendLine("  $($q.ThreatName)  Severity:$($q.SeverityID)  Category:$($q.CategoryID)  Active:$($q.IsActive)") | Out-Null
18022        }}
18023        $result.AppendLine("") | Out-Null
18024    }} else {{
18025        $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18026        $result.AppendLine("  No quarantined threats found.") | Out-Null
18027        $result.AppendLine("") | Out-Null
18028    }}
18029}} catch {{
18030    $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18031    $result.AppendLine("  Unable to query quarantine list: $_") | Out-Null
18032    $result.AppendLine("") | Out-Null
18033}}
18034
18035# Defender scan stats
18036try {{
18037    $status = Get-MpComputerStatus -ErrorAction SilentlyContinue
18038    if ($status) {{
18039        $lastScan   = $status.QuickScanStartTime
18040        $lastFull   = $status.FullScanStartTime
18041        $sigDate    = $status.AntivirusSignatureLastUpdated
18042        $result.AppendLine("=== Defender Scan Summary ===") | Out-Null
18043        $result.AppendLine("  Last quick scan : $lastScan") | Out-Null
18044        $result.AppendLine("  Last full scan  : $lastFull") | Out-Null
18045        $result.AppendLine("  Signature date  : $sigDate") | Out-Null
18046    }}
18047}} catch {{}}
18048
18049Write-Output $result.ToString().TrimEnd()
18050"#,
18051        limit = limit
18052    );
18053    let out = run_powershell(&script)?;
18054    Ok(format!("Host inspection: defender_quarantine\n\n{out}"))
18055}
18056
18057// ── inspect_domain_health ─────────────────────────────────────────────────────
18058
18059#[cfg(windows)]
18060fn inspect_domain_health() -> Result<String, String> {
18061    let script = r#"
18062$result = [System.Text.StringBuilder]::new()
18063
18064# Domain membership
18065try {
18066    $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
18067    $joined = $cs.PartOfDomain
18068    $domain = $cs.Domain
18069    $result.AppendLine("=== Domain Membership ===") | Out-Null
18070    $result.AppendLine("  Join Status : $(if ($joined) { 'DOMAIN JOINED' } else { 'WORKGROUP' })") | Out-Null
18071    if ($joined) { $result.AppendLine("  Domain      : $domain") | Out-Null }
18072    $result.AppendLine("  Computer    : $($cs.Name)") | Out-Null
18073} catch {
18074    $result.AppendLine("  Domain membership check failed: $_") | Out-Null
18075}
18076
18077# dsregcmd device registration state
18078try {
18079    $dsreg = dsregcmd /status 2>&1 | Where-Object { $_ -match '(AzureAdJoined|DomainJoined|WorkplaceJoined|TenantName|DeviceId|MdmUrl)' }
18080    if ($dsreg) {
18081        $result.AppendLine("") | Out-Null
18082        $result.AppendLine("=== Device Registration (dsregcmd) ===") | Out-Null
18083        foreach ($line in $dsreg) { $result.AppendLine("  $($line.Trim())") | Out-Null }
18084    }
18085} catch {}
18086
18087# DC discovery via nltest
18088$result.AppendLine("") | Out-Null
18089$result.AppendLine("=== Domain Controller Discovery ===") | Out-Null
18090try {
18091    $nl = nltest /dsgetdc:. 2>&1
18092    $dc_name = $null
18093    foreach ($line in $nl) {
18094        if ($line -match '(DC:|Address:|Dom Guid|DomainName)') {
18095            $result.AppendLine("  $($line.Trim())") | Out-Null
18096        }
18097        if ($line -match 'DC: \\\\(.+)') { $dc_name = $Matches[1].Trim() }
18098    }
18099    if ($dc_name) {
18100        $result.AppendLine("") | Out-Null
18101        $result.AppendLine("=== DC Port Connectivity (DC: $dc_name) ===") | Out-Null
18102        foreach ($entry in @(@{p=389;n='LDAP'},@{p=636;n='LDAPS'},@{p=88;n='Kerberos'},@{p=3268;n='GC-LDAP'})) {
18103            try {
18104                $tcp = New-Object System.Net.Sockets.TcpClient
18105                $conn = $tcp.BeginConnect($dc_name, $entry.p, $null, $null)
18106                $ok = $conn.AsyncWaitHandle.WaitOne(1200, $false)
18107                $tcp.Close()
18108                $status = if ($ok) { 'OPEN' } else { 'TIMEOUT' }
18109            } catch { $status = 'FAILED' }
18110            $result.AppendLine("  Port $($entry.p) ($($entry.n)): $status") | Out-Null
18111        }
18112    }
18113} catch {
18114    $result.AppendLine("  nltest unavailable (not domain-joined or missing RSAT): $_") | Out-Null
18115}
18116
18117# Last GPO machine refresh time
18118try {
18119    $gpoKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\Extension-List\{00000000-0000-0000-0000-000000000000}'
18120    if (Test-Path $gpoKey) {
18121        $gpo = Get-ItemProperty $gpoKey -ErrorAction SilentlyContinue
18122        $result.AppendLine("") | Out-Null
18123        $result.AppendLine("=== Group Policy Last Refresh ===") | Out-Null
18124        $result.AppendLine("  Machine GPO last applied: $($gpo.EndTime)") | Out-Null
18125    }
18126} catch {}
18127
18128Write-Output $result.ToString().TrimEnd()
18129"#;
18130    let out = run_powershell(script)?;
18131    Ok(format!("Host inspection: domain_health\n\n{out}"))
18132}
18133
18134#[cfg(not(windows))]
18135fn inspect_domain_health() -> Result<String, String> {
18136    let mut out = String::from("Host inspection: domain_health\n\n");
18137    for cmd_args in &[vec!["realm", "list"], vec!["sssd", "--version"]] {
18138        if let Ok(o) = Command::new(cmd_args[0]).args(&cmd_args[1..]).output() {
18139            let s = String::from_utf8_lossy(&o.stdout);
18140            if !s.trim().is_empty() {
18141                out.push_str(&format!("$ {}\n{}\n", cmd_args.join(" "), s.trim_end()));
18142            }
18143        }
18144    }
18145    if out.trim_end().ends_with("domain_health") {
18146        out.push_str("Not domain-joined or realm/sssd not installed.\n");
18147    }
18148    Ok(out)
18149}
18150
18151// ── inspect_service_dependencies ─────────────────────────────────────────────
18152
18153#[cfg(windows)]
18154fn inspect_service_dependencies(max_entries: usize) -> Result<String, String> {
18155    let limit = max_entries.min(60);
18156    let script = format!(
18157        r#"
18158$result = [System.Text.StringBuilder]::new()
18159$result.AppendLine("=== Service Dependency Graph ===") | Out-Null
18160$result.AppendLine("Format: [Status] ServiceName — requires: ... | needed by: ...") | Out-Null
18161$result.AppendLine("") | Out-Null
18162$svc = Get-Service | Where-Object {{ $_.DependentServices.Count -gt 0 -or $_.RequiredServices.Count -gt 0 }} | Sort-Object Name | Select-Object -First {limit}
18163foreach ($s in $svc) {{
18164    $req  = if ($s.RequiredServices.Count  -gt 0) {{ "requires: $($s.RequiredServices.Name  -join ', ')" }} else {{ "" }}
18165    $dep  = if ($s.DependentServices.Count -gt 0) {{ "needed by: $($s.DependentServices.Name -join ', ')" }} else {{ "" }}
18166    $parts = @($req, $dep) | Where-Object {{ $_ }}
18167    if ($parts) {{
18168        $result.AppendLine("  [$($s.Status)] $($s.Name) — $($parts -join ' | ')") | Out-Null
18169    }}
18170}}
18171Write-Output $result.ToString().TrimEnd()
18172"#,
18173        limit = limit
18174    );
18175    let out = run_powershell(&script)?;
18176    Ok(format!("Host inspection: service_dependencies\n\n{out}"))
18177}
18178
18179#[cfg(not(windows))]
18180fn inspect_service_dependencies(_max_entries: usize) -> Result<String, String> {
18181    let out = Command::new("systemctl")
18182        .args(["list-dependencies", "--no-pager", "--plain"])
18183        .output()
18184        .ok()
18185        .and_then(|o| String::from_utf8(o.stdout).ok())
18186        .unwrap_or_else(|| "systemctl not available.\n".to_string());
18187    Ok(format!(
18188        "Host inspection: service_dependencies\n\n{}",
18189        out.trim_end()
18190    ))
18191}
18192
18193// ── inspect_wmi_health ────────────────────────────────────────────────────────
18194
18195#[cfg(windows)]
18196fn inspect_wmi_health() -> Result<String, String> {
18197    let script = r#"
18198$result = [System.Text.StringBuilder]::new()
18199$result.AppendLine("=== WMI Repository Health ===") | Out-Null
18200
18201# Basic WMI query test
18202try {
18203    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
18204    $result.AppendLine("  Query (Win32_OperatingSystem): OK") | Out-Null
18205    $result.AppendLine("  OS: $($os.Caption) Build $($os.BuildNumber)") | Out-Null
18206} catch {
18207    $result.AppendLine("  Query FAILED: $_") | Out-Null
18208    $result.AppendLine("  FINDING: WMI may be corrupt. Run winmgmt /verifyrepository") | Out-Null
18209}
18210
18211# Repository integrity
18212try {
18213    $verify = & winmgmt /verifyrepository 2>&1
18214    $result.AppendLine("  winmgmt /verifyrepository: $verify") | Out-Null
18215} catch {
18216    $result.AppendLine("  winmgmt check unavailable: $_") | Out-Null
18217}
18218
18219# WMI service state
18220$svc = Get-Service winmgmt -ErrorAction SilentlyContinue
18221if ($svc) {
18222    $result.AppendLine("  Service (winmgmt): $($svc.Status) / $($svc.StartType)") | Out-Null
18223}
18224
18225# Repository folder size
18226$repPath = "$env:SystemRoot\System32\wbem\Repository"
18227if (Test-Path $repPath) {
18228    $bytes = (Get-ChildItem $repPath -Recurse -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
18229    $mb = [math]::Round($bytes / 1MB, 1)
18230    $result.AppendLine("  Repository size: $mb MB  ($repPath)") | Out-Null
18231    if ($mb -gt 200) {
18232        $result.AppendLine("  FINDING: Repository is unusually large (>200 MB). May indicate corruption or bloat.") | Out-Null
18233    }
18234}
18235
18236$result.AppendLine("") | Out-Null
18237$result.AppendLine("=== Recovery Steps (if corrupt) ===") | Out-Null
18238$result.AppendLine("  1. net stop winmgmt") | Out-Null
18239$result.AppendLine("  2. winmgmt /salvagerepository   (try first)") | Out-Null
18240$result.AppendLine("  3. winmgmt /resetrepository     (last resort — loses custom namespaces)") | Out-Null
18241$result.AppendLine("  4. net start winmgmt") | Out-Null
18242
18243Write-Output $result.ToString().TrimEnd()
18244"#;
18245    let out = run_powershell(script)?;
18246    Ok(format!("Host inspection: wmi_health\n\n{out}"))
18247}
18248
18249#[cfg(not(windows))]
18250fn inspect_wmi_health() -> Result<String, String> {
18251    Ok("Host inspection: wmi_health\n\nWMI is Windows-only. On Linux use 'systemctl status' for service health.\n".to_string())
18252}
18253
18254// ── inspect_local_security_policy ────────────────────────────────────────────
18255
18256#[cfg(windows)]
18257fn inspect_local_security_policy() -> Result<String, String> {
18258    let script = r#"
18259$result = [System.Text.StringBuilder]::new()
18260$result.AppendLine("=== Local Account & Password Policy ===") | Out-Null
18261$na = net accounts 2>&1
18262foreach ($line in $na) {
18263    if ($line -match '(password|lockout|observation|force|maximum|minimum|length|duration)') {
18264        $result.AppendLine("  $($line.Trim())") | Out-Null
18265    }
18266}
18267
18268$result.AppendLine("") | Out-Null
18269$result.AppendLine("=== LAN Manager / NTLM Authentication Level ===") | Out-Null
18270try {
18271    $lmLevel = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue).LmCompatibilityLevel
18272    if ($null -eq $lmLevel) { $lmLevel = 3 }
18273    $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'}
18274    $result.AppendLine("  LmCompatibilityLevel: $lmLevel — $($map[$lmLevel])") | Out-Null
18275    if ($lmLevel -lt 3) {
18276        $result.AppendLine("  FINDING: LmCompatibilityLevel < 3 allows weak LM/NTLM. Recommend level 3 or higher.") | Out-Null
18277    }
18278} catch {}
18279
18280$result.AppendLine("") | Out-Null
18281$result.AppendLine("=== UAC Settings ===") | Out-Null
18282try {
18283    $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue
18284    if ($uac) {
18285        $result.AppendLine("  UAC Enabled             : $($uac.EnableLUA)   (1=on, 0=disabled)") | Out-Null
18286        $behavMap = @{0='No prompt (risk)'; 1='Prompt for creds'; 2='Prompt for consent'; 5='Secure consent (default)'}
18287        $bval = $uac.ConsentPromptBehaviorAdmin
18288        $result.AppendLine("  Admin Prompt Behavior   : $bval — $($behavMap[$bval])") | Out-Null
18289        if ($uac.EnableLUA -eq 0) {
18290            $result.AppendLine("  FINDING: UAC is disabled. Processes run with full admin rights without prompting.") | Out-Null
18291        }
18292    }
18293} catch {}
18294
18295Write-Output $result.ToString().TrimEnd()
18296"#;
18297    let out = run_powershell(script)?;
18298    Ok(format!("Host inspection: local_security_policy\n\n{out}"))
18299}
18300
18301#[cfg(not(windows))]
18302fn inspect_local_security_policy() -> Result<String, String> {
18303    let mut out = String::from("Host inspection: local_security_policy\n\n");
18304    if let Ok(content) = std::fs::read_to_string("/etc/login.defs") {
18305        out.push_str("=== /etc/login.defs ===\n");
18306        for line in content.lines() {
18307            let t = line.trim();
18308            if !t.is_empty() && !t.starts_with('#') {
18309                out.push_str(&format!("  {t}\n"));
18310            }
18311        }
18312    }
18313    Ok(out)
18314}
18315
18316// ── inspect_usb_history ───────────────────────────────────────────────────────
18317
18318#[cfg(windows)]
18319fn inspect_usb_history(max_entries: usize) -> Result<String, String> {
18320    let limit = max_entries.min(50);
18321    let script = format!(
18322        r#"
18323$result = [System.Text.StringBuilder]::new()
18324$result.AppendLine("=== USB Device History (USBSTOR Registry) ===") | Out-Null
18325$usbPath = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBSTOR'
18326if (Test-Path $usbPath) {{
18327    $count = 0
18328    $seen = @{{}}
18329    $classes = Get-ChildItem $usbPath -ErrorAction SilentlyContinue
18330    foreach ($class in $classes) {{
18331        $instances = Get-ChildItem $class.PSPath -ErrorAction SilentlyContinue
18332        foreach ($inst in $instances) {{
18333            if ($count -ge {limit}) {{ break }}
18334            try {{
18335                $props = Get-ItemProperty $inst.PSPath -ErrorAction SilentlyContinue
18336                $fn = if ($props.FriendlyName) {{ $props.FriendlyName }} else {{ $class.PSChildName }}
18337                if (-not $seen[$fn]) {{
18338                    $seen[$fn] = $true
18339                    $result.AppendLine("  $fn") | Out-Null
18340                    $count++
18341                }}
18342            }} catch {{}}
18343        }}
18344    }}
18345    if ($count -eq 0) {{
18346        $result.AppendLine("  No USB storage devices found in registry.") | Out-Null
18347    }} else {{
18348        $result.AppendLine("") | Out-Null
18349        $result.AppendLine("  ($count unique devices; requires elevation for full history)") | Out-Null
18350    }}
18351}} else {{
18352    $result.AppendLine("  USBSTOR key not found. Requires elevation or USB storage policy may block it.") | Out-Null
18353}}
18354Write-Output $result.ToString().TrimEnd()
18355"#,
18356        limit = limit
18357    );
18358    let out = run_powershell(&script)?;
18359    Ok(format!("Host inspection: usb_history\n\n{out}"))
18360}
18361
18362#[cfg(not(windows))]
18363fn inspect_usb_history(_max_entries: usize) -> Result<String, String> {
18364    let mut out = String::from("Host inspection: usb_history\n\n");
18365    if let Ok(o) = Command::new("journalctl")
18366        .args(["-k", "--no-pager", "-q", "--since", "30 days ago"])
18367        .output()
18368    {
18369        let s = String::from_utf8_lossy(&o.stdout);
18370        let usb_lines: Vec<&str> = s
18371            .lines()
18372            .filter(|l| l.to_ascii_lowercase().contains("usb"))
18373            .take(30)
18374            .collect();
18375        if !usb_lines.is_empty() {
18376            out.push_str("=== Recent USB kernel events (journalctl) ===\n");
18377            for line in usb_lines {
18378                out.push_str(&format!("  {line}\n"));
18379            }
18380        }
18381    } else {
18382        out.push_str("USB history via journalctl not available.\n");
18383    }
18384    Ok(out)
18385}
18386
18387// ── inspect_print_spooler ─────────────────────────────────────────────────────
18388
18389#[cfg(windows)]
18390fn inspect_print_spooler() -> Result<String, String> {
18391    let script = r#"
18392$result = [System.Text.StringBuilder]::new()
18393
18394$svc = Get-Service Spooler -ErrorAction SilentlyContinue
18395$result.AppendLine("=== Print Spooler Service ===") | Out-Null
18396if ($svc) {
18397    $result.AppendLine("  Status     : $($svc.Status)") | Out-Null
18398    $result.AppendLine("  Start Type : $($svc.StartType)") | Out-Null
18399} else {
18400    $result.AppendLine("  Spooler service not found.") | Out-Null
18401}
18402
18403# PrintNightmare mitigations (CVE-2021-34527)
18404$result.AppendLine("") | Out-Null
18405$result.AppendLine("=== PrintNightmare Hardening (CVE-2021-34527) ===") | Out-Null
18406try {
18407    $val = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print' -Name RpcAuthnLevelPrivacyEnabled -ErrorAction SilentlyContinue).RpcAuthnLevelPrivacyEnabled
18408    if ($val -eq 1) {
18409        $result.AppendLine("  RpcAuthnLevelPrivacyEnabled: 1 — HARDENED (good)") | Out-Null
18410    } else {
18411        $result.AppendLine("  RpcAuthnLevelPrivacyEnabled: $val — NOT hardened") | Out-Null
18412        $result.AppendLine("  FINDING: PrintNightmare RPC mitigation not applied. Set to 1 via registry.") | Out-Null
18413    }
18414} catch { $result.AppendLine("  Mitigation key not readable: $_") | Out-Null }
18415
18416try {
18417    $pnpPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint'
18418    if (Test-Path $pnpPath) {
18419        $pnp = Get-ItemProperty $pnpPath -ErrorAction SilentlyContinue
18420        $result.AppendLine("  RestrictDriverInstallationToAdministrators: $($pnp.RestrictDriverInstallationToAdministrators)") | Out-Null
18421        $result.AppendLine("  NoWarningNoElevationOnInstall              : $($pnp.NoWarningNoElevationOnInstall)") | Out-Null
18422        if ($pnp.NoWarningNoElevationOnInstall -eq 1) {
18423            $result.AppendLine("  FINDING: Point and Print allows silent driver install without elevation (risk).") | Out-Null
18424        }
18425    } else {
18426        $result.AppendLine("  No Point and Print policy (using Windows defaults).") | Out-Null
18427    }
18428} catch {}
18429
18430# Pending print jobs
18431$result.AppendLine("") | Out-Null
18432$result.AppendLine("=== Print Queue ===") | Out-Null
18433try {
18434    $jobs = Get-PrintJob -ErrorAction SilentlyContinue
18435    if ($jobs) {
18436        foreach ($j in $jobs | Select-Object -First 5) {
18437            $result.AppendLine("  $($j.DocumentName) — $($j.JobStatus)") | Out-Null
18438        }
18439    } else {
18440        $result.AppendLine("  No pending print jobs.") | Out-Null
18441    }
18442} catch {
18443    $result.AppendLine("  Print queue check requires elevation.") | Out-Null
18444}
18445
18446Write-Output $result.ToString().TrimEnd()
18447"#;
18448    let out = run_powershell(script)?;
18449    Ok(format!("Host inspection: print_spooler\n\n{out}"))
18450}
18451
18452#[cfg(not(windows))]
18453fn inspect_print_spooler() -> Result<String, String> {
18454    let mut out = String::from("Host inspection: print_spooler\n\n");
18455    if let Ok(o) = Command::new("lpstat").args(["-s"]).output() {
18456        let s = String::from_utf8_lossy(&o.stdout);
18457        if !s.trim().is_empty() {
18458            out.push_str("=== CUPS Status (lpstat -s) ===\n");
18459            out.push_str(s.trim_end());
18460            out.push('\n');
18461        }
18462    } else {
18463        out.push_str("CUPS not detected (lpstat not found).\n");
18464    }
18465    Ok(out)
18466}
18467
18468#[cfg(not(windows))]
18469fn inspect_defender_quarantine(_max_entries: usize) -> Result<String, String> {
18470    let mut out = String::from("Host inspection: defender_quarantine\n\n");
18471    out.push_str("Windows Defender is Windows-only.\n");
18472    // Check ClamAV on Linux/macOS
18473    if let Ok(o) = Command::new("clamscan").arg("--version").output() {
18474        if o.status.success() {
18475            out.push_str("\nClamAV detected. Run `clamscan -r --infected /path` for a scan.\n");
18476            if let Ok(log) = std::fs::read_to_string("/var/log/clamav/clamav.log") {
18477                out.push_str("\n=== ClamAV Recent Log ===\n");
18478                for line in log.lines().rev().take(20) {
18479                    out.push_str(&format!("  {line}\n"));
18480                }
18481            }
18482        }
18483    } else {
18484        out.push_str("No AV tool detected (ClamAV not found).\n");
18485    }
18486    Ok(out)
18487}