Skip to main content

hematite/tools/
host_inspect.rs

1use crate::agent::truncation::safe_head;
2use serde_json::Value;
3use std::collections::HashSet;
4use std::fmt::Write as _;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9const DEFAULT_MAX_ENTRIES: usize = 10;
10const MAX_ENTRIES_CAP: usize = 25;
11const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
12
13pub async fn inspect_host(args: &Value) -> Result<String, String> {
14    let mut topic = args
15        .get("topic")
16        .and_then(|v| v.as_str())
17        .unwrap_or("summary")
18        .to_string();
19    let max_entries = parse_max_entries(args);
20    let filter = parse_name_filter(args).unwrap_or_default().to_lowercase();
21
22    // Topic Interceptor: Force ad_user for AD-related queries to resolve model variance
23    if (topic == "processes" || topic == "network" || topic == "summary")
24        && (filter.contains("ad")
25            || filter.contains("sid")
26            || filter.contains("administrator")
27            || filter.contains("domain"))
28    {
29        topic = "ad_user".to_string();
30    }
31
32    let result = match topic.as_str() {
33        "summary" => inspect_summary(max_entries),
34        "toolchains" => inspect_toolchains(),
35        "path" => inspect_path(max_entries),
36        "env_doctor" => inspect_env_doctor(max_entries),
37        "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
38        "network" => inspect_network(max_entries),
39        "lan_discovery" | "network_neighborhood" | "upnp" | "neighborhood" => {
40            inspect_lan_discovery(max_entries)
41        }
42        "audio" | "sound" | "microphone" | "speakers" | "speaker" | "mic" => {
43            inspect_audio(max_entries)
44        }
45        "bluetooth" | "bt" | "paired_devices" | "wireless_audio" => {
46            inspect_bluetooth(max_entries)
47        }
48        "camera" | "webcam" | "camera_privacy" => inspect_camera(max_entries),
49        "sign_in" | "windows_hello" | "hello" | "pin" | "login_issues" | "signin" => {
50            inspect_sign_in(max_entries)
51        }
52        "installer_health" | "installer" | "msi" | "msiexec" | "app_installer" => {
53            inspect_installer_health(max_entries)
54        }
55        "onedrive" | "sync_client" | "cloud_sync" | "known_folder_backup" => {
56            inspect_onedrive(max_entries)
57        }
58        "browser_health" | "browser" | "webview2" | "default_browser" => {
59            inspect_browser_health(max_entries)
60        }
61        "identity_auth"
62        | "office_auth"
63        | "m365_auth"
64        | "microsoft_365_auth"
65        | "auth_broker" => inspect_identity_auth(max_entries),
66        "outlook" | "outlook_health" | "ms_outlook" => inspect_outlook(max_entries),
67        "teams" | "ms_teams" | "teams_health" => inspect_teams(max_entries),
68        "windows_backup" | "backup" | "file_history" | "wbadmin" | "system_restore" => {
69            inspect_windows_backup(max_entries)
70        }
71        "search_index" | "windows_search" | "indexing" | "search" => {
72            inspect_search_index(max_entries)
73        }
74        "services" => inspect_services(parse_name_filter(args), max_entries),
75        "processes" => inspect_processes(parse_name_filter(args), max_entries),
76        "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
77        "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
78        "disk" => {
79            let path = resolve_optional_path(args)?;
80            inspect_disk(path, max_entries).await
81        }
82        "ports" => inspect_ports(parse_port_filter(args), max_entries),
83        "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
84        "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
85        "health_report" | "system_health" => inspect_health_report(),
86        "storage" => inspect_storage(max_entries),
87        "storage_deep" | "disk_deep" | "where_is_space" => inspect_storage_deep(),
88        "hardware" => inspect_hardware(),
89        "updates" | "windows_update" => inspect_updates(),
90        "security" | "antivirus" | "defender" => inspect_security(),
91        "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
92        "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
93        "battery" => inspect_battery(),
94        "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
95        "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
96        "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
97        "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
98        "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
99        "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
100        "vpn" => inspect_vpn(),
101        "public_ip" | "external_ip" | "myip" => inspect_public_ip().await,
102        "ssl_cert" | "tls_cert" | "cert_audit" | "website_cert" => {
103            let host = args.get("host").and_then(|v| v.as_str()).unwrap_or("google.com");
104            inspect_ssl_cert(host)
105        }
106        "proxy" | "proxy_settings" => inspect_proxy(),
107        "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
108        "traceroute" | "tracert" | "trace_route" | "trace" => {
109            let host = args
110                .get("host")
111                .and_then(|v| v.as_str())
112                .unwrap_or("8.8.8.8")
113                .to_string();
114            inspect_traceroute(&host, max_entries)
115        }
116        "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
117        "arp" | "arp_table" => inspect_arp(),
118        "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
119        "os_config" | "system_config" => inspect_os_config(),
120        "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
121        "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
122        "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
123        "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
124        "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
125            inspect_docker_filesystems(max_entries)
126        }
127        "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
128        "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
129        "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
130        "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
131        "git_config" | "git_global" => inspect_git_config(),
132        "databases" | "database" | "db_services" | "db" => inspect_databases(),
133        "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
134        "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
135        "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
136        "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
137        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
138        "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
139        "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
140        "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
141        "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
142        "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
143        "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
144        "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
145        "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
146        "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
147        "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
148        "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
149        "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
150        "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
151        "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
152        "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
153        "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
154        "data_audit" | "csv_audit" | "file_audit" => {
155            let path = resolve_optional_path(args)?;
156            inspect_data_audit(path, max_entries).await
157        }
158        "repo_doctor" => {
159            let path = resolve_optional_path(args)?;
160            inspect_repo_doctor(path, max_entries)
161        }
162        "directory" => {
163            let raw_path = args
164                .get("path")
165                .and_then(|v| v.as_str())
166                .ok_or_else(|| {
167                    "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
168                        .to_string()
169                })?;
170            let resolved = resolve_path(raw_path)?;
171            inspect_directory("Directory", resolved, max_entries).await
172        }
173        "disk_benchmark" | "stress_test" | "io_intensity" => {
174            let path = resolve_optional_path(args)?;
175            inspect_disk_benchmark(path).await
176        }
177        "permissions" | "acl" | "access_control" => {
178            let path = resolve_optional_path(args)?;
179            inspect_permissions(path, max_entries)
180        }
181        "login_history" | "logon_history" | "user_logins" => {
182            inspect_login_history(max_entries)
183        }
184        "share_access" | "unc_access" | "remote_share" => {
185            let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
186            inspect_share_access(path)
187        }
188        "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
189        "thermal" | "throttling" | "overheating" => inspect_thermal(),
190        "activation" | "license_status" | "slmgr" => inspect_activation(),
191        "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
192        "ad_user" | "ad" | "domain_user" => {
193            let identity = parse_name_filter(args).unwrap_or_default();
194            inspect_ad_user(&identity)
195        }
196        "dns_lookup" | "dig" | "nslookup" => {
197            let name = parse_name_filter(args).unwrap_or_default();
198            let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("A");
199            inspect_dns_lookup(&name, record_type)
200        }
201        "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
202        "ip_config" | "ip_detail" => inspect_ip_config(),
203        "dhcp" | "dhcp_lease" | "lease" | "dhcp_detail" => inspect_dhcp(),
204        "mtu" | "path_mtu" | "pmtu" | "frame_size" | "mtu_discovery" => inspect_mtu(),
205        "ipv6" | "ipv6_status" | "ipv6_address" | "ipv6_prefix" | "ipv6_config" | "slaac" | "dhcpv6" => inspect_ipv6(),
206        "tcp_params" | "tcp_settings" | "tcp_autotuning" | "tcp_config" | "tcp_tuning" | "tcp_window" => inspect_tcp_params(),
207        "wlan_profiles" | "wifi_profiles" | "wireless_profiles" | "saved_wifi" | "saved_networks" => inspect_wlan_profiles(),
208        "ipsec" | "ipsec_sa" | "ipsec_policy" | "ipsec_rules" | "ipsec_tunnel" | "ike" => inspect_ipsec(),
209        "netbios" | "netbios_status" | "wins" | "nbtstat" | "netbios_config" => inspect_netbios(),
210        "nic_teaming" | "nic_team" | "teaming" | "lacp" | "bonding" | "link_aggregation" => inspect_nic_teaming(),
211        "snmp" | "snmp_agent" | "snmp_service" | "snmp_config" => inspect_snmp(),
212        "port_test" | "port_check" | "test_port" | "check_port" | "tcp_test" | "reachable" => {
213            let pt_host = args.get("host").and_then(|v| v.as_str()).map(|s| s.to_string());
214            let pt_port = args.get("port").and_then(|v| v.as_u64()).and_then(|p| u16::try_from(p).ok());
215            inspect_port_test(pt_host.as_deref(), pt_port)
216        }
217        "network_profile" | "network_location" | "net_profile" | "network_category" => inspect_network_profile(),
218        "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
219        "display_config" | "display" | "monitor" | "monitors" | "resolution" | "refresh_rate" | "screen" => {
220            inspect_display_config(max_entries)
221        }
222        "ntp" | "time_sync" | "time_synchronization" | "clock_sync" | "w32tm" | "clock" => {
223            inspect_ntp()
224        }
225        "cpu_power" | "turbo_boost" | "cpu_frequency" | "cpu_freq" | "processor_power" | "boost" => {
226            inspect_cpu_power()
227        }
228        "credentials" | "credential_manager" | "saved_passwords" | "stored_credentials" => {
229            inspect_credentials(max_entries)
230        }
231        "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
232            inspect_tpm()
233        }
234        "latency" | "ping" | "ping_test" | "rtt" | "packet_loss" | "reachability" => {
235            inspect_latency()
236        }
237        "network_adapter" | "nic" | "nic_settings" | "adapter_settings" | "nic_offload" | "nic_advanced" => {
238            inspect_network_adapter()
239        }
240        "event_query" | "event_log" | "events" | "event_search" | "eventlog" => {
241            let event_id = args.get("event_id").and_then(|v| v.as_u64()).and_then(|n| u32::try_from(n).ok());
242            let log_name = args.get("log").and_then(|v| v.as_str()).map(|s| s.to_string());
243            let source = args.get("source").and_then(|v| v.as_str()).map(|s| s.to_string());
244            let hours = args.get("hours").and_then(|v| v.as_u64()).and_then(|h| u32::try_from(h).ok()).unwrap_or(24u32);
245            let level = args.get("level").and_then(|v| v.as_str()).map(|s| s.to_string());
246            inspect_event_query(event_id, log_name.as_deref(), source.as_deref(), hours, level.as_deref(), max_entries)
247        }
248        "app_crashes" | "application_crashes" | "app_errors" | "application_errors" | "faulting_application" => {
249            let process_filter = args.get("process").and_then(|v| v.as_str()).map(|s| s.to_string());
250            inspect_app_crashes(process_filter.as_deref(), max_entries)
251        }
252        "mdm_enrollment" | "mdm" | "intune" | "intune_enrollment" | "device_enrollment" | "autopilot" => {
253            inspect_mdm_enrollment()
254        }
255        "storage_spaces" | "storage_pool" | "storage_pools" | "virtual_disk" | "virtual_disks" | "windows_raid" => {
256            inspect_storage_spaces()
257        }
258        "defender_quarantine" | "quarantine" | "threat_history" | "malware_history" | "defender_history" | "detected_threats" => {
259            inspect_defender_quarantine(max_entries)
260        }
261        "domain_health" | "dc_connectivity" | "ad_connectivity" | "kerberos_health" => {
262            inspect_domain_health()
263        }
264        "service_dependencies" | "svc_deps" | "service_deps" => {
265            inspect_service_dependencies(max_entries)
266        }
267        "wmi_health" | "wmi_repository" | "wmi_status" => {
268            inspect_wmi_health()
269        }
270        "local_security_policy" | "password_policy" | "account_policy" | "ntlm_policy" | "lm_policy" => {
271            inspect_local_security_policy()
272        }
273        "usb_history" | "usb_devices" | "usb_forensics" | "usbstor" => {
274            inspect_usb_history(max_entries)
275        }
276        "print_spooler" | "spooler" | "printnightmare" | "print_security" | "printer_security" => {
277            inspect_print_spooler()
278        }
279        other => Err(format!(
280            "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.",
281            other
282        )),
283
284    };
285
286    result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
287}
288
289fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
290    let Some(scope) = admin_sensitive_topic_scope(topic) else {
291        return body;
292    };
293    let lower = body.to_lowercase();
294    let privilege_limited = lower.contains("access denied")
295        || lower.contains("administrator privilege is required")
296        || lower.contains("administrator privileges required")
297        || lower.contains("requires administrator")
298        || lower.contains("requires elevation")
299        || lower.contains("non-admin session")
300        || lower.contains("could not be fully determined from this session");
301    if !privilege_limited || lower.contains("=== elevation note ===") {
302        return body;
303    }
304
305    let mut annotated = body;
306    annotated.push_str("\n=== Elevation note ===\n");
307    annotated.push_str("- Hematite should stay non-admin by default.\n");
308    annotated.push_str(
309        "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
310    );
311    let _ = writeln!(
312        annotated,
313        "- Rerun Hematite as Administrator only if you need a definitive {scope} answer."
314    );
315    annotated
316}
317
318fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
319    match topic {
320        "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
321            Some("TPM / Secure Boot / firmware")
322        }
323        "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
324        "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
325        "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
326        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
327        "windows_features" | "optional_features" | "installed_features" | "features" => {
328            Some("Windows Features")
329        }
330        "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
331        _ => None,
332    }
333}
334
335#[cfg(test)]
336mod privilege_hint_tests {
337    use super::annotate_privilege_limited_output;
338
339    #[test]
340    fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
341        let body = "Host inspection: network\nError: Access denied.\n".to_string();
342        let annotated = annotate_privilege_limited_output("network", body.clone());
343        assert_eq!(annotated, body);
344    }
345
346    #[test]
347    fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
348        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();
349        let annotated = annotate_privilege_limited_output("tpm", body);
350        assert!(annotated.contains("=== Elevation note ==="));
351        assert!(annotated.contains("stay non-admin by default"));
352        assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
353    }
354}
355
356#[cfg(test)]
357mod event_query_tests {
358    use super::is_event_query_no_results_message;
359
360    #[cfg(target_os = "windows")]
361    #[test]
362    fn treats_windows_no_results_message_as_empty_query() {
363        assert!(is_event_query_no_results_message(
364            "No events were found that match the specified selection criteria."
365        ));
366    }
367
368    #[cfg(target_os = "windows")]
369    #[test]
370    fn does_not_treat_real_errors_as_empty_query() {
371        assert!(!is_event_query_no_results_message("Access is denied."));
372    }
373}
374
375fn parse_max_entries(args: &Value) -> usize {
376    args.get("max_entries")
377        .and_then(|v| v.as_u64())
378        .map(|n| n as usize)
379        .unwrap_or(DEFAULT_MAX_ENTRIES)
380        .clamp(1, MAX_ENTRIES_CAP)
381}
382
383fn parse_port_filter(args: &Value) -> Option<u16> {
384    args.get("port")
385        .and_then(|v| v.as_u64())
386        .and_then(|n| u16::try_from(n).ok())
387}
388
389fn parse_name_filter(args: &Value) -> Option<String> {
390    args.get("name")
391        .and_then(|v| v.as_str())
392        .map(str::trim)
393        .filter(|value| !value.is_empty())
394        .map(|value| value.to_string())
395}
396
397fn parse_lookback_hours(args: &Value) -> Option<u32> {
398    args.get("lookback_hours")
399        .and_then(|v| v.as_u64())
400        .map(|n| n as u32)
401}
402
403fn parse_issue_text(args: &Value) -> Option<String> {
404    args.get("issue")
405        .and_then(|v| v.as_str())
406        .map(str::trim)
407        .filter(|value| !value.is_empty())
408        .map(|value| value.to_string())
409}
410
411// Escape a string for embedding inside a PowerShell single-quoted string literal.
412// In PS single-quoted strings the only special sequence is '' (escaped single quote).
413fn ps_escape_single_quoted(s: &str) -> String {
414    s.replace('\'', "''")
415}
416
417// Restrict DNS record type to a known-safe allowlist; fall back to "A" for unknown input.
418fn validate_dns_record_type(record_type: &str) -> &str {
419    match record_type.to_uppercase().as_str() {
420        "A" | "AAAA" | "MX" | "TXT" | "SRV" | "CNAME" | "NS" | "PTR" | "SOA" | "CAA" | "NAPTR"
421        | "DS" | "DNSKEY" | "ANY" => record_type,
422        _ => "A",
423    }
424}
425
426#[cfg(target_os = "windows")]
427fn is_event_query_no_results_message(message: &str) -> bool {
428    let lower = message.to_ascii_lowercase();
429    lower.contains("no events were found")
430        || lower.contains("no events match the specified selection criteria")
431}
432
433fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
434    match args.get("path").and_then(|v| v.as_str()) {
435        Some(raw_path) => resolve_path(raw_path),
436        None => {
437            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
438        }
439    }
440}
441
442fn inspect_summary(max_entries: usize) -> Result<String, String> {
443    let current_dir =
444        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
445    let workspace_root = crate::tools::file_ops::workspace_root();
446    let workspace_mode = workspace_mode_label(&workspace_root);
447    let path_stats = analyze_path_env();
448    let toolchains = collect_toolchains();
449
450    let mut out = String::from("Host inspection: summary\n\n");
451    let _ = writeln!(out, "- OS: {}", std::env::consts::OS);
452    let _ = writeln!(out, "- Current directory: {}", current_dir.display());
453    let _ = writeln!(out, "- Workspace root: {}", workspace_root.display());
454    let _ = writeln!(out, "- Workspace mode: {}", workspace_mode);
455    let _ = writeln!(out, "- Preferred shell: {}", preferred_shell_label());
456    let _ = writeln!(
457        out,
458        "- PATH entries: {} total, {} unique, {} duplicates, {} missing",
459        path_stats.total_entries,
460        path_stats.unique_entries,
461        path_stats.duplicate_entries.len(),
462        path_stats.missing_entries.len()
463    );
464
465    if toolchains.found.is_empty() {
466        out.push_str(
467            "- Toolchains found: none of the common developer tools were detected on PATH\n",
468        );
469    } else {
470        out.push_str("- Toolchains found:\n");
471        for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
472            let _ = writeln!(out, "  - {}: {}", label, version);
473        }
474        if toolchains.found.len() > max_entries.min(8) {
475            let _ = writeln!(
476                out,
477                "  - ... {} more found tools omitted",
478                toolchains.found.len() - max_entries.min(8)
479            );
480        }
481    }
482
483    if !toolchains.missing.is_empty() {
484        let _ = writeln!(
485            out,
486            "- Common tools not detected on PATH: {}",
487            toolchains.missing.join(", ")
488        );
489    }
490
491    for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
492        match path {
493            Some(path) if path.exists() => match count_top_level_items(&path) {
494                Ok(count) => {
495                    let _ = writeln!(
496                        out,
497                        "- {}: {} top-level items at {}",
498                        label,
499                        count,
500                        path.display()
501                    );
502                }
503                Err(e) => {
504                    let _ = writeln!(
505                        out,
506                        "- {}: exists at {} but could not inspect ({})",
507                        label,
508                        path.display(),
509                        e
510                    );
511                }
512            },
513            Some(path) => {
514                let _ = writeln!(
515                    out,
516                    "- {}: expected at {} but not found",
517                    label,
518                    path.display()
519                );
520            }
521            None => {
522                let _ = writeln!(out, "- {}: location unavailable on this host", label);
523            }
524        }
525    }
526
527    Ok(out.trim_end().to_string())
528}
529
530fn inspect_toolchains() -> Result<String, String> {
531    let report = collect_toolchains();
532    let mut out = String::from("Host inspection: toolchains\n\n");
533
534    if report.found.is_empty() {
535        out.push_str("- No common developer tools were detected on PATH.");
536    } else {
537        out.push_str("Detected developer tools:\n");
538        for (label, version) in report.found {
539            let _ = writeln!(out, "- {}: {}", label, version);
540        }
541    }
542
543    if !report.missing.is_empty() {
544        out.push_str("\nNot detected on PATH:\n");
545        for label in report.missing {
546            let _ = writeln!(out, "- {}", label);
547        }
548    }
549
550    Ok(out.trim_end().to_string())
551}
552
553fn inspect_path(max_entries: usize) -> Result<String, String> {
554    let path_stats = analyze_path_env();
555    let mut out = String::from("Host inspection: PATH\n\n");
556    let _ = writeln!(out, "- Total entries: {}", path_stats.total_entries);
557    let _ = writeln!(out, "- Unique entries: {}", path_stats.unique_entries);
558    let _ = writeln!(
559        out,
560        "- Duplicate entries: {}",
561        path_stats.duplicate_entries.len()
562    );
563    let _ = writeln!(out, "- Missing paths: {}", path_stats.missing_entries.len());
564
565    out.push_str("\nPATH entries:\n");
566    for entry in path_stats.entries.iter().take(max_entries) {
567        let _ = writeln!(out, "- {}", entry);
568    }
569    if path_stats.entries.len() > max_entries {
570        let _ = writeln!(
571            out,
572            "- ... {} more entries omitted",
573            path_stats.entries.len() - max_entries
574        );
575    }
576
577    if !path_stats.duplicate_entries.is_empty() {
578        out.push_str("\nDuplicate entries:\n");
579        for entry in path_stats.duplicate_entries.iter().take(max_entries) {
580            let _ = writeln!(out, "- {}", entry);
581        }
582        if path_stats.duplicate_entries.len() > max_entries {
583            let _ = writeln!(
584                out,
585                "- ... {} more duplicates omitted",
586                path_stats.duplicate_entries.len() - max_entries
587            );
588        }
589    }
590
591    if !path_stats.missing_entries.is_empty() {
592        out.push_str("\nMissing directories:\n");
593        for entry in path_stats.missing_entries.iter().take(max_entries) {
594            let _ = writeln!(out, "- {}", entry);
595        }
596        if path_stats.missing_entries.len() > max_entries {
597            let _ = writeln!(
598                out,
599                "- ... {} more missing entries omitted",
600                path_stats.missing_entries.len() - max_entries
601            );
602        }
603    }
604
605    Ok(out.trim_end().to_string())
606}
607
608fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
609    let path_stats = analyze_path_env();
610    let toolchains = collect_toolchains();
611    let package_managers = collect_package_managers();
612    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
613
614    let mut out = String::from("Host inspection: env_doctor\n\n");
615    let _ = writeln!(
616        out,
617        "- PATH health: {} duplicates, {} missing entries",
618        path_stats.duplicate_entries.len(),
619        path_stats.missing_entries.len()
620    );
621    let _ = writeln!(out, "- Toolchains found: {}", toolchains.found.len());
622    let _ = writeln!(
623        out,
624        "- Package managers found: {}",
625        package_managers.found.len()
626    );
627
628    if !package_managers.found.is_empty() {
629        out.push_str("\nPackage managers:\n");
630        for (label, version) in package_managers.found.iter().take(max_entries) {
631            let _ = writeln!(out, "- {}: {}", label, version);
632        }
633        if package_managers.found.len() > max_entries {
634            let _ = writeln!(
635                out,
636                "- ... {} more package managers omitted",
637                package_managers.found.len() - max_entries
638            );
639        }
640    }
641
642    if !path_stats.duplicate_entries.is_empty() {
643        out.push_str("\nDuplicate PATH entries:\n");
644        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
645            let _ = writeln!(out, "- {}", entry);
646        }
647        if path_stats.duplicate_entries.len() > max_entries.min(5) {
648            let _ = writeln!(
649                out,
650                "- ... {} more duplicate entries omitted",
651                path_stats.duplicate_entries.len() - max_entries.min(5)
652            );
653        }
654    }
655
656    if !path_stats.missing_entries.is_empty() {
657        out.push_str("\nMissing PATH entries:\n");
658        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
659            let _ = writeln!(out, "- {}", entry);
660        }
661        if path_stats.missing_entries.len() > max_entries.min(5) {
662            let _ = writeln!(
663                out,
664                "- ... {} more missing entries omitted",
665                path_stats.missing_entries.len() - max_entries.min(5)
666            );
667        }
668    }
669
670    if !findings.is_empty() {
671        out.push_str("\nFindings:\n");
672        for finding in findings.iter().take(max_entries.max(5)) {
673            let _ = writeln!(out, "- {}", finding);
674        }
675        if findings.len() > max_entries.max(5) {
676            let _ = writeln!(
677                out,
678                "- ... {} more findings omitted",
679                findings.len() - max_entries.max(5)
680            );
681        }
682    } else {
683        out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
684    }
685
686    out.push_str(
687        "\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.",
688    );
689
690    Ok(out.trim_end().to_string())
691}
692
693#[derive(Clone, Copy, Debug, Eq, PartialEq)]
694enum FixPlanKind {
695    EnvPath,
696    PortConflict,
697    LmStudio,
698    DriverInstall,
699    GroupPolicy,
700    FirewallRule,
701    SshKey,
702    WslSetup,
703    ServiceConfig,
704    WindowsActivation,
705    RegistryEdit,
706    ScheduledTaskCreate,
707    DiskCleanup,
708    DnsResolution,
709    Generic,
710}
711
712async fn inspect_fix_plan(
713    issue: Option<String>,
714    port_filter: Option<u16>,
715    max_entries: usize,
716) -> Result<String, String> {
717    let issue = issue.unwrap_or_else(|| {
718        "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
719            .to_string()
720    });
721    let plan_kind = classify_fix_plan_kind(&issue, port_filter);
722    match plan_kind {
723        FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
724        FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
725        FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
726        FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
727        FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
728        FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
729        FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
730        FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
731        FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
732        FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
733        FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
734        FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
735        FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
736        FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
737        FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
738    }
739}
740
741fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
742    let lower = issue.to_ascii_lowercase();
743    // FirewallRule must be checked before PortConflict — "open port 80 in the firewall"
744    // is firewall rule creation, not a port ownership conflict.
745    if lower.contains("firewall rule")
746        || lower.contains("inbound rule")
747        || lower.contains("outbound rule")
748        || (lower.contains("firewall")
749            && (lower.contains("allow")
750                || lower.contains("block")
751                || lower.contains("create")
752                || lower.contains("open")))
753    {
754        FixPlanKind::FirewallRule
755    } else if port_filter.is_some()
756        || lower.contains("port ")
757        || lower.contains("address already in use")
758        || lower.contains("already in use")
759        || lower.contains("what owns port")
760        || lower.contains("listening on port")
761    {
762        FixPlanKind::PortConflict
763    } else if lower.contains("lm studio")
764        || lower.contains("localhost:1234")
765        || lower.contains("/v1/models")
766        || lower.contains("no coding model loaded")
767        || lower.contains("embedding model")
768        || lower.contains("server on port 1234")
769        || lower.contains("runtime refresh")
770    {
771        FixPlanKind::LmStudio
772    } else if lower.contains("driver")
773        || lower.contains("gpu driver")
774        || lower.contains("nvidia driver")
775        || lower.contains("amd driver")
776        || lower.contains("install driver")
777        || lower.contains("update driver")
778    {
779        FixPlanKind::DriverInstall
780    } else if lower.contains("group policy")
781        || lower.contains("gpedit")
782        || lower.contains("local policy")
783        || lower.contains("secpol")
784        || lower.contains("administrative template")
785    {
786        FixPlanKind::GroupPolicy
787    } else if lower.contains("ssh key")
788        || lower.contains("ssh-keygen")
789        || lower.contains("generate ssh")
790        || lower.contains("authorized_keys")
791        || lower.contains("id_rsa")
792        || lower.contains("id_ed25519")
793    {
794        FixPlanKind::SshKey
795    } else if lower.contains("wsl")
796        || lower.contains("windows subsystem for linux")
797        || lower.contains("install ubuntu")
798        || lower.contains("install linux on windows")
799        || lower.contains("wsl2")
800    {
801        FixPlanKind::WslSetup
802    } else if lower.contains("service")
803        && (lower.contains("start ")
804            || lower.contains("stop ")
805            || lower.contains("restart ")
806            || lower.contains("enable ")
807            || lower.contains("disable ")
808            || lower.contains("configure service"))
809    {
810        FixPlanKind::ServiceConfig
811    } else if lower.contains("activate windows")
812        || lower.contains("windows activation")
813        || lower.contains("product key")
814        || lower.contains("kms")
815        || lower.contains("not activated")
816    {
817        FixPlanKind::WindowsActivation
818    } else if lower.contains("registry")
819        || lower.contains("regedit")
820        || lower.contains("hklm")
821        || lower.contains("hkcu")
822        || lower.contains("reg add")
823        || lower.contains("reg delete")
824        || lower.contains("registry key")
825    {
826        FixPlanKind::RegistryEdit
827    } else if lower.contains("scheduled task")
828        || lower.contains("task scheduler")
829        || lower.contains("schtasks")
830        || lower.contains("create task")
831        || lower.contains("run on startup")
832        || lower.contains("run on schedule")
833        || lower.contains("cron")
834    {
835        FixPlanKind::ScheduledTaskCreate
836    } else if lower.contains("disk cleanup")
837        || lower.contains("free up disk")
838        || lower.contains("free up space")
839        || lower.contains("clear cache")
840        || lower.contains("disk full")
841        || lower.contains("low disk space")
842        || lower.contains("reclaim space")
843    {
844        FixPlanKind::DiskCleanup
845    } else if lower.contains("cargo")
846        || lower.contains("rustc")
847        || lower.contains("path")
848        || lower.contains("package manager")
849        || lower.contains("package managers")
850        || lower.contains("toolchain")
851        || lower.contains("winget")
852        || lower.contains("choco")
853        || lower.contains("scoop")
854        || lower.contains("python")
855        || lower.contains("node")
856    {
857        FixPlanKind::EnvPath
858    } else if lower.contains("dns ")
859        || lower.contains("nameserver")
860        || lower.contains("cannot resolve")
861        || lower.contains("nslookup")
862        || lower.contains("flushdns")
863    {
864        FixPlanKind::DnsResolution
865    } else {
866        FixPlanKind::Generic
867    }
868}
869
870fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
871    let path_stats = analyze_path_env();
872    let toolchains = collect_toolchains();
873    let package_managers = collect_package_managers();
874    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
875    let found_tools = toolchains
876        .found
877        .iter()
878        .map(|(label, _)| label.as_str())
879        .collect::<HashSet<_>>();
880    let found_managers = package_managers
881        .found
882        .iter()
883        .map(|(label, _)| label.as_str())
884        .collect::<HashSet<_>>();
885
886    let mut out = String::from("Host inspection: fix_plan\n\n");
887    let _ = writeln!(out, "- Requested issue: {}", issue);
888    out.push_str("- Fix-plan type: environment/path\n");
889    let _ = writeln!(
890        out,
891        "- PATH health: {} duplicates, {} missing entries",
892        path_stats.duplicate_entries.len(),
893        path_stats.missing_entries.len()
894    );
895    let _ = writeln!(out, "- Toolchains found: {}", toolchains.found.len());
896    let _ = writeln!(
897        out,
898        "- Package managers found: {}",
899        package_managers.found.len()
900    );
901
902    out.push_str("\nLikely causes:\n");
903    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
904        out.push_str(
905            "- 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",
906        );
907    }
908    if path_stats.duplicate_entries.is_empty()
909        && path_stats.missing_entries.is_empty()
910        && !findings.is_empty()
911    {
912        for finding in findings.iter().take(max_entries.max(4)) {
913            let _ = writeln!(out, "- {}", finding);
914        }
915    } else {
916        if !path_stats.duplicate_entries.is_empty() {
917            out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
918        }
919        if !path_stats.missing_entries.is_empty() {
920            out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
921        }
922    }
923    if found_tools.contains("node")
924        && !found_managers.contains("npm")
925        && !found_managers.contains("pnpm")
926    {
927        out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
928    }
929    if found_tools.contains("python")
930        && !found_managers.contains("pip")
931        && !found_managers.contains("uv")
932        && !found_managers.contains("pipx")
933    {
934        out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
935    }
936
937    out.push_str("\nFix plan:\n");
938    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");
939    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
940        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");
941    } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
942        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");
943    }
944    if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
945        out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
946    }
947    if found_tools.contains("node")
948        && !found_managers.contains("npm")
949        && !found_managers.contains("pnpm")
950    {
951        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");
952    }
953    if found_tools.contains("python")
954        && !found_managers.contains("pip")
955        && !found_managers.contains("uv")
956        && !found_managers.contains("pipx")
957    {
958        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");
959    }
960
961    if !path_stats.duplicate_entries.is_empty() {
962        out.push_str("\nExample duplicate PATH rows:\n");
963        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
964            let _ = writeln!(out, "- {}", entry);
965        }
966    }
967    if !path_stats.missing_entries.is_empty() {
968        out.push_str("\nExample missing PATH rows:\n");
969        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
970            let _ = writeln!(out, "- {}", entry);
971        }
972    }
973
974    out.push_str(
975        "\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.",
976    );
977    Ok(out.trim_end().to_string())
978}
979
980fn inspect_port_fix_plan(
981    issue: &str,
982    port_filter: Option<u16>,
983    max_entries: usize,
984) -> Result<String, String> {
985    let requested_port = port_filter.or_else(|| first_port_in_text(issue));
986    let listeners = collect_listening_ports().unwrap_or_default();
987    let mut matching = listeners;
988    if let Some(port) = requested_port {
989        matching.retain(|entry| entry.port == port);
990    }
991    let processes = collect_processes().unwrap_or_default();
992
993    let mut out = String::from("Host inspection: fix_plan\n\n");
994    let _ = writeln!(out, "- Requested issue: {}", issue);
995    out.push_str("- Fix-plan type: port_conflict\n");
996    if let Some(port) = requested_port {
997        let _ = writeln!(out, "- Requested port: {}", port);
998    } else {
999        out.push_str("- Requested port: not parsed from the issue text\n");
1000    }
1001    let _ = writeln!(out, "- Matching listeners found: {}", matching.len());
1002
1003    if !matching.is_empty() {
1004        out.push_str("\nCurrent listeners:\n");
1005        for entry in matching.iter().take(max_entries.min(5)) {
1006            let process_name = entry
1007                .pid
1008                .as_deref()
1009                .and_then(|pid| pid.parse::<u32>().ok())
1010                .and_then(|pid| {
1011                    processes
1012                        .iter()
1013                        .find(|process| process.pid == pid)
1014                        .map(|process| process.name.as_str())
1015                })
1016                .unwrap_or("unknown");
1017            let pid = entry.pid.as_deref().unwrap_or("unknown");
1018            let _ = writeln!(
1019                out,
1020                "- {} {} ({}) pid {} process {}",
1021                entry.protocol, entry.local, entry.state, pid, process_name
1022            );
1023        }
1024    }
1025
1026    out.push_str("\nFix plan:\n");
1027    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");
1028    if !matching.is_empty() {
1029        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");
1030    } else {
1031        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");
1032    }
1033    out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
1034    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");
1035    out.push_str(
1036        "\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.",
1037    );
1038    Ok(out.trim_end().to_string())
1039}
1040
1041async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
1042    let config = crate::agent::config::load_config();
1043    let configured_api = config
1044        .api_url
1045        .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
1046    let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
1047    let reachability = probe_http_endpoint(&models_url).await;
1048    let embed_model = detect_loaded_embed_model(&configured_api).await;
1049
1050    let mut out = String::from("Host inspection: fix_plan\n\n");
1051    let _ = writeln!(out, "- Requested issue: {}", issue);
1052    out.push_str("- Fix-plan type: lm_studio\n");
1053    let _ = writeln!(out, "- Configured API URL: {}", configured_api);
1054    let _ = writeln!(out, "- Probe URL: {}", models_url);
1055    match &reachability {
1056        EndpointProbe::Reachable(status) => {
1057            let _ = writeln!(out, "- Endpoint reachable: yes (HTTP {})", status);
1058        }
1059        EndpointProbe::Unreachable(detail) => {
1060            let _ = writeln!(out, "- Endpoint reachable: no ({})", detail);
1061        }
1062    }
1063    let _ = writeln!(
1064        out,
1065        "- Embedding model loaded: {}",
1066        embed_model.as_deref().unwrap_or("none detected")
1067    );
1068
1069    out.push_str("\nFix plan:\n");
1070    match reachability {
1071        EndpointProbe::Reachable(_) => {
1072            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");
1073        }
1074        EndpointProbe::Unreachable(_) => {
1075            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");
1076        }
1077    }
1078    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");
1079    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");
1080    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");
1081    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");
1082    if let Some(model) = embed_model {
1083        let _ = writeln!(out,
1084            "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.",
1085            model
1086        );
1087    }
1088    if max_entries > 0 {
1089        out.push_str(
1090            "\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.",
1091        );
1092    }
1093    Ok(out.trim_end().to_string())
1094}
1095
1096fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1097    // Read GPU info from the hardware topic output for grounding
1098    #[cfg(target_os = "windows")]
1099    let gpu_info = {
1100        let out = Command::new("powershell")
1101            .args([
1102                "-NoProfile",
1103                "-NonInteractive",
1104                "-Command",
1105                "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1106            ])
1107            .output()
1108            .ok()
1109            .and_then(|o| String::from_utf8(o.stdout).ok())
1110            .unwrap_or_default();
1111        out.trim().to_string()
1112    };
1113    #[cfg(not(target_os = "windows"))]
1114    let gpu_info = String::from("(GPU detection not available on this platform)");
1115
1116    let mut out = String::from("Host inspection: fix_plan\n\n");
1117    let _ = writeln!(out, "- Requested issue: {}", issue);
1118    out.push_str("- Fix-plan type: driver_install\n");
1119    if !gpu_info.is_empty() {
1120        let _ = write!(out, "\nDetected GPU(s):\n{}\n", gpu_info);
1121    }
1122    out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1123    out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1124    out.push_str(
1125        "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1126    );
1127    out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1128    out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1129    out.push_str("   - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1130    out.push_str("   - AMD: amd.com/support (use Auto-Detect tool)\n");
1131    out.push_str("   - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1132    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");
1133    out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1134    out.push_str("\nVerification:\n");
1135    out.push_str("- After reboot, run in PowerShell:\n  Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1136    out.push_str("- The DriverVersion should match what you installed.\n");
1137    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.");
1138    Ok(out.trim_end().to_string())
1139}
1140
1141fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1142    // Check Windows edition — Group Policy editor is not available on Home editions
1143    #[cfg(target_os = "windows")]
1144    let edition = {
1145        Command::new("powershell")
1146            .args([
1147                "-NoProfile",
1148                "-NonInteractive",
1149                "-Command",
1150                "(Get-CimInstance Win32_OperatingSystem).Caption",
1151            ])
1152            .output()
1153            .ok()
1154            .and_then(|o| String::from_utf8(o.stdout).ok())
1155            .unwrap_or_default()
1156            .trim()
1157            .to_string()
1158    };
1159    #[cfg(not(target_os = "windows"))]
1160    let edition = String::from("(Windows edition detection not available)");
1161
1162    let is_home = edition.to_lowercase().contains("home");
1163
1164    let mut out = String::from("Host inspection: fix_plan\n\n");
1165    let _ = writeln!(out, "- Requested issue: {}", issue);
1166    out.push_str("- Fix-plan type: group_policy\n");
1167    let _ = writeln!(
1168        out,
1169        "- Windows edition detected: {}",
1170        if edition.is_empty() {
1171            "unknown".to_string()
1172        } else {
1173            edition.clone()
1174        }
1175    );
1176
1177    if is_home {
1178        out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1179        out.push_str("Options on Home edition:\n");
1180        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");
1181        out.push_str(
1182            "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1183        );
1184        out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1185    } else {
1186        out.push_str("\nFix plan — Editing Local Group Policy:\n");
1187        out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1188        out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1189        out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1190        out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1191        out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1192        out.push_str("6. To force immediate application, run in an elevated PowerShell:\n  gpupdate /force\n");
1193    }
1194    out.push_str("\nVerification:\n");
1195    out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1196    out.push_str(
1197        "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1198    );
1199    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.");
1200    Ok(out.trim_end().to_string())
1201}
1202
1203fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1204    #[cfg(target_os = "windows")]
1205    let profile_state = {
1206        Command::new("powershell")
1207            .args([
1208                "-NoProfile",
1209                "-NonInteractive",
1210                "-Command",
1211                "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1212            ])
1213            .output()
1214            .ok()
1215            .and_then(|o| String::from_utf8(o.stdout).ok())
1216            .unwrap_or_default()
1217            .trim()
1218            .to_string()
1219    };
1220    #[cfg(not(target_os = "windows"))]
1221    let profile_state = String::new();
1222
1223    let mut out = String::from("Host inspection: fix_plan\n\n");
1224    let _ = writeln!(out, "- Requested issue: {}", issue);
1225    out.push_str("- Fix-plan type: firewall_rule\n");
1226    if !profile_state.is_empty() {
1227        let _ = write!(out, "\nFirewall profile state:\n{}\n", profile_state);
1228    }
1229    out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1230    out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1231    out.push_str("  New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1232    out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1233    out.push_str("  New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1234    out.push_str("\nTo ALLOW an application through the firewall:\n");
1235    out.push_str("  New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1236    out.push_str("\nTo REMOVE a rule you created:\n");
1237    out.push_str("  Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1238    out.push_str("\nTo see existing custom rules:\n");
1239    out.push_str("  Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1240    out.push_str("\nVerification:\n");
1241    out.push_str("- After creating the rule, test reachability from another machine or use:\n  Test-NetConnection -ComputerName localhost -Port 8080\n");
1242    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.");
1243    Ok(out.trim_end().to_string())
1244}
1245
1246fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1247    let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1248    let ssh_dir = home.join(".ssh");
1249    let has_ssh_dir = ssh_dir.exists();
1250    let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1251    let has_rsa = ssh_dir.join("id_rsa").exists();
1252    let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1253
1254    let mut out = String::from("Host inspection: fix_plan\n\n");
1255    let _ = writeln!(out, "- Requested issue: {}", issue);
1256    out.push_str("- Fix-plan type: ssh_key\n");
1257    let _ = writeln!(out, "- ~/.ssh directory exists: {}", has_ssh_dir);
1258    let _ = writeln!(out, "- id_ed25519 key found: {}", has_ed25519);
1259    let _ = writeln!(out, "- id_rsa key found: {}", has_rsa);
1260    let _ = writeln!(out, "- authorized_keys found: {}", has_authorized_keys);
1261
1262    if has_ed25519 {
1263        out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1264    }
1265
1266    out.push_str("\nFix plan — Generating an SSH key pair:\n");
1267    out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1268    out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1269    out.push_str("   ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1270    out.push_str(
1271        "   - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1272    );
1273    out.push_str("   - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1274    out.push_str("3. Start the SSH agent and add your key:\n");
1275    out.push_str("   # Windows (PowerShell, run as Admin once to enable the service):\n");
1276    out.push_str("   Set-Service -Name ssh-agent -StartupType Automatic\n");
1277    out.push_str("   Start-Service ssh-agent\n");
1278    out.push_str("   # Then add the key (normal PowerShell):\n");
1279    out.push_str("   ssh-add ~/.ssh/id_ed25519\n");
1280    out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1281    out.push_str("   # Print your public key:\n");
1282    out.push_str("   cat ~/.ssh/id_ed25519.pub\n");
1283    out.push_str("   # On the target server, append it:\n");
1284    out.push_str("   echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1285    out.push_str("   chmod 600 ~/.ssh/authorized_keys\n");
1286    out.push_str("5. Test the connection:\n");
1287    out.push_str("   ssh user@server-address\n");
1288    out.push_str("\nFor GitHub/GitLab:\n");
1289    out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1290    out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1291    out.push_str("- Test: ssh -T git@github.com\n");
1292    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.");
1293    Ok(out.trim_end().to_string())
1294}
1295
1296fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1297    #[cfg(target_os = "windows")]
1298    let wsl_status = {
1299        let out = Command::new("wsl")
1300            .args(["--status"])
1301            .output()
1302            .ok()
1303            .map(|o| {
1304                let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1305                let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1306                format!("{}{}", stdout, stderr)
1307            })
1308            .unwrap_or_default();
1309        out.trim().to_string()
1310    };
1311    #[cfg(not(target_os = "windows"))]
1312    let wsl_status = String::new();
1313
1314    let wsl_installed =
1315        !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1316
1317    let mut out = String::from("Host inspection: fix_plan\n\n");
1318    let _ = writeln!(out, "- Requested issue: {}", issue);
1319    out.push_str("- Fix-plan type: wsl_setup\n");
1320    let _ = writeln!(out, "- WSL already installed: {}", wsl_installed);
1321    if !wsl_status.is_empty() {
1322        let _ = write!(out, "- WSL status:\n{}\n", wsl_status);
1323    }
1324
1325    if wsl_installed {
1326        out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1327        out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1328        out.push_str("   Available distros: wsl --list --online\n");
1329        out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1330        out.push_str("3. Create your Linux username and password when prompted.\n");
1331    } else {
1332        out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1333        out.push_str("1. Open PowerShell as Administrator.\n");
1334        out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1335        out.push_str("   wsl --install\n");
1336        out.push_str("   (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1337        out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1338        out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1339        out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1340        out.push_str("   wsl --set-default-version 2\n");
1341        out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1342        out.push_str("   wsl --install -d Debian\n");
1343        out.push_str("   wsl --list --online   # to see all available distros\n");
1344    }
1345    out.push_str("\nVerification:\n");
1346    out.push_str("- Run: wsl --list --verbose\n");
1347    out.push_str("- You should see your distro with State: Running and Version: 2\n");
1348    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.");
1349    Ok(out.trim_end().to_string())
1350}
1351
1352fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1353    let lower = issue.to_ascii_lowercase();
1354    // Extract service name hints from the issue text
1355    let service_hint = if lower.contains("ssh") {
1356        Some("sshd")
1357    } else if lower.contains("mysql") {
1358        Some("MySQL80")
1359    } else if lower.contains("postgres") || lower.contains("postgresql") {
1360        Some("postgresql")
1361    } else if lower.contains("redis") {
1362        Some("Redis")
1363    } else if lower.contains("nginx") {
1364        Some("nginx")
1365    } else if lower.contains("apache") {
1366        Some("Apache2.4")
1367    } else {
1368        None
1369    };
1370
1371    #[cfg(target_os = "windows")]
1372    let service_state = if let Some(svc) = service_hint {
1373        Command::new("powershell")
1374            .args([
1375                "-NoProfile",
1376                "-NonInteractive",
1377                "-Command",
1378                &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1379            ])
1380            .output()
1381            .ok()
1382            .and_then(|o| String::from_utf8(o.stdout).ok())
1383            .unwrap_or_default()
1384            .trim()
1385            .to_string()
1386    } else {
1387        String::new()
1388    };
1389    #[cfg(not(target_os = "windows"))]
1390    let service_state = String::new();
1391
1392    let mut out = String::from("Host inspection: fix_plan\n\n");
1393    let _ = writeln!(out, "- Requested issue: {}", issue);
1394    out.push_str("- Fix-plan type: service_config\n");
1395    if let Some(svc) = service_hint {
1396        let _ = writeln!(out, "- Service detected in request: {}", svc);
1397    }
1398    if !service_state.is_empty() {
1399        let _ = writeln!(out, "- Current state: {}", service_state);
1400    }
1401
1402    out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1403    out.push_str("\nStart a service:\n");
1404    out.push_str("  Start-Service -Name \"ServiceName\"\n");
1405    out.push_str("\nStop a service:\n");
1406    out.push_str("  Stop-Service -Name \"ServiceName\"\n");
1407    out.push_str("\nRestart a service:\n");
1408    out.push_str("  Restart-Service -Name \"ServiceName\"\n");
1409    out.push_str("\nEnable a service to start automatically:\n");
1410    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1411    out.push_str("\nDisable a service (stops it from auto-starting):\n");
1412    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1413    out.push_str("\nFind the exact service name:\n");
1414    out.push_str("  Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1415    out.push_str("\nVerification:\n");
1416    out.push_str("  Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1417    if let Some(svc) = service_hint {
1418        let _ = write!(
1419            out,
1420            "\nFor your detected service ({}):\n  Get-Service -Name '{}'\n",
1421            svc, svc
1422        );
1423    }
1424    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.");
1425    Ok(out.trim_end().to_string())
1426}
1427
1428fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1429    #[cfg(target_os = "windows")]
1430    let activation_status = {
1431        Command::new("powershell")
1432            .args([
1433                "-NoProfile",
1434                "-NonInteractive",
1435                "-Command",
1436                "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 + ')' })\" }",
1437            ])
1438            .output()
1439            .ok()
1440            .and_then(|o| String::from_utf8(o.stdout).ok())
1441            .unwrap_or_default()
1442            .trim()
1443            .to_string()
1444    };
1445    #[cfg(not(target_os = "windows"))]
1446    let activation_status = String::new();
1447
1448    let activation_lower = activation_status.to_lowercase();
1449    let is_licensed =
1450        activation_lower.contains("licensed") && !activation_lower.contains("not licensed");
1451
1452    let mut out = String::from("Host inspection: fix_plan\n\n");
1453    let _ = writeln!(out, "- Requested issue: {}", issue);
1454    out.push_str("- Fix-plan type: windows_activation\n");
1455    if !activation_status.is_empty() {
1456        let _ = write!(out, "- Current activation state:\n{}\n", activation_status);
1457    }
1458
1459    if is_licensed {
1460        out.push_str(
1461            "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1462        );
1463        out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1464        out.push_str("   (Forces an online activation attempt)\n");
1465        out.push_str("2. Check activation details: slmgr /dli\n");
1466    } else {
1467        out.push_str("\nFix plan — Activating Windows:\n");
1468        out.push_str("1. Check your current status first:\n");
1469        out.push_str("   slmgr /dli   (basic info)\n");
1470        out.push_str("   slmgr /dlv   (detailed — shows remaining rearms, grace period)\n");
1471        out.push_str("\n2. If you have a retail product key:\n");
1472        out.push_str("   slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX   (install key)\n");
1473        out.push_str("   slmgr /ato                                   (activate online)\n");
1474        out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1475        out.push_str("   - Go to Settings → System → Activation\n");
1476        out.push_str("   - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1477        out.push_str("   - Sign in with the Microsoft account that holds the license\n");
1478        out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1479        out.push_str("   - Contact your IT department for the KMS server address\n");
1480        out.push_str("   - Set KMS host: slmgr /skms kms.yourorg.com\n");
1481        out.push_str("   - Activate:    slmgr /ato\n");
1482    }
1483    out.push_str("\nVerification:\n");
1484    out.push_str("  slmgr /dli   — should show 'License Status: Licensed'\n");
1485    out.push_str("  Or: Settings → System → Activation → 'Windows is activated'\n");
1486    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.");
1487    Ok(out.trim_end().to_string())
1488}
1489
1490fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1491    let mut out = String::from("Host inspection: fix_plan\n\n");
1492    let _ = writeln!(out, "- Requested issue: {}", issue);
1493    out.push_str("- Fix-plan type: registry_edit\n");
1494    out.push_str(
1495        "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1496    );
1497    out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1498    out.push_str("\n1. Back up before you touch anything:\n");
1499    out.push_str("   # Export the key you're about to change (PowerShell):\n");
1500    out.push_str("   reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1501    out.push_str("   # Or export the whole registry (takes a while):\n");
1502    out.push_str("   reg export HKLM C:\\backup\\HKLM_full.reg\n");
1503    out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1504    out.push_str("   Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1505    out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1506    out.push_str(
1507        "   Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1508    );
1509    out.push_str("\n4. Create a new key:\n");
1510    out.push_str("   New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1511    out.push_str("\n5. Delete a value:\n");
1512    out.push_str("   Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1513    out.push_str("\n6. Restore from backup if something breaks:\n");
1514    out.push_str("   reg import C:\\backup\\MyKey_backup.reg\n");
1515    out.push_str("\nCommon registry hives:\n");
1516    out.push_str("  HKLM = HKEY_LOCAL_MACHINE  (machine-wide, requires Admin)\n");
1517    out.push_str("  HKCU = HKEY_CURRENT_USER   (current user, no elevation needed)\n");
1518    out.push_str("  HKCR = HKEY_CLASSES_ROOT    (file associations)\n");
1519    out.push_str("\nVerification:\n");
1520    out.push_str("  Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1521    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.");
1522    Ok(out.trim_end().to_string())
1523}
1524
1525fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1526    let mut out = String::from("Host inspection: fix_plan\n\n");
1527    let _ = writeln!(out, "- Requested issue: {}", issue);
1528    out.push_str("- Fix-plan type: scheduled_task_create\n");
1529    out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1530    out.push_str("\nExample: Run a script at 9 AM every day\n");
1531    out.push_str("  $action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1532    out.push_str("  $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1533    out.push_str("  Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1534    out.push_str("\nExample: Run at Windows startup\n");
1535    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1536    out.push_str("  Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1537    out.push_str("\nExample: Run at user logon\n");
1538    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1539    out.push_str(
1540        "  Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1541    );
1542    out.push_str("\nExample: Run every 30 minutes\n");
1543    out.push_str("  $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1544    out.push_str("\nView all tasks:\n");
1545    out.push_str("  Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1546    out.push_str("\nDelete a task:\n");
1547    out.push_str("  Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1548    out.push_str("\nRun a task immediately:\n");
1549    out.push_str("  Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1550    out.push_str("\nVerification:\n");
1551    out.push_str("  Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1552    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.");
1553    Ok(out.trim_end().to_string())
1554}
1555
1556fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1557    #[cfg(target_os = "windows")]
1558    let disk_info = {
1559        Command::new("powershell")
1560            .args([
1561                "-NoProfile",
1562                "-NonInteractive",
1563                "-Command",
1564                "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\" }",
1565            ])
1566            .output()
1567            .ok()
1568            .and_then(|o| String::from_utf8(o.stdout).ok())
1569            .unwrap_or_default()
1570            .trim()
1571            .to_string()
1572    };
1573    #[cfg(not(target_os = "windows"))]
1574    let disk_info = String::new();
1575
1576    let mut out = String::from("Host inspection: fix_plan\n\n");
1577    let _ = writeln!(out, "- Requested issue: {}", issue);
1578    out.push_str("- Fix-plan type: disk_cleanup\n");
1579    if !disk_info.is_empty() {
1580        let _ = write!(out, "\nCurrent drive usage:\n{}\n", disk_info);
1581    }
1582    out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1583    out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1584    out.push_str("   cleanmgr /sageset:1    (configure what to clean)\n");
1585    out.push_str("   cleanmgr /sagerun:1    (run the cleanup)\n");
1586    out.push_str("   Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1587    out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1588    out.push_str("   Stop-Service wuauserv\n");
1589    out.push_str("   Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1590    out.push_str("   Start-Service wuauserv\n");
1591    out.push_str("\n3. Clear Windows Temp folder:\n");
1592    out.push_str("   Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1593    out.push_str(
1594        "   Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1595    );
1596    out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1597    out.push_str("   - Rust build artifacts: cargo clean  (inside each project)\n");
1598    out.push_str("   - npm cache:  npm cache clean --force\n");
1599    out.push_str("   - pip cache:  pip cache purge\n");
1600    out.push_str(
1601        "   - Docker:     docker system prune -a  (removes all unused images/containers)\n",
1602    );
1603    out.push_str("   - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force  (will redownload on next build)\n");
1604    out.push_str("\n5. Check for large files:\n");
1605    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");
1606    out.push_str("\nVerification:\n");
1607    out.push_str(
1608        "  Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1609    );
1610    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.");
1611    Ok(out.trim_end().to_string())
1612}
1613
1614fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1615    let mut out = String::from("Host inspection: fix_plan\n\n");
1616    let _ = writeln!(out, "- Requested issue: {}", issue);
1617    out.push_str("- Fix-plan type: generic\n");
1618    out.push_str(
1619        "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1620         Structured lanes available:\n\
1621         - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1622         - Port conflict (address already in use, what owns port)\n\
1623         - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1624         - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1625         - Group Policy (gpedit, local policy, administrative template)\n\
1626         - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1627         - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1628         - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1629         - Service config (start/stop/restart/enable/disable a service)\n\
1630         - Windows activation (product key, not activated, kms)\n\
1631         - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1632         - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1633         - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1634         - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1635    );
1636    Ok(out.trim_end().to_string())
1637}
1638
1639fn inspect_resource_load() -> Result<String, String> {
1640    #[cfg(target_os = "windows")]
1641    {
1642        let output = Command::new("powershell")
1643            .args([
1644                "-NoProfile",
1645                "-Command",
1646                "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1647            ])
1648            .output()
1649            .map_err(|e| format!("Failed to run powershell: {e}"))?;
1650
1651        let text = String::from_utf8_lossy(&output.stdout);
1652        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1653
1654        let cpu_load = lines
1655            .next()
1656            .and_then(|l| l.parse::<u32>().ok())
1657            .unwrap_or(0);
1658        let mem_json: String = lines.collect();
1659        let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1660
1661        let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1662        let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1663        let used_kb = total_kb.saturating_sub(free_kb);
1664        let mem_percent = (used_kb * 100).checked_div(total_kb).unwrap_or(0);
1665
1666        let mut out = String::from("Host inspection: resource_load\n\n");
1667        out.push_str("**System Performance Summary:**\n");
1668        let _ = writeln!(out, "- CPU Load: {}%", cpu_load);
1669        let _ = writeln!(
1670            out,
1671            "- Memory Usage: {} / {} ({}%)",
1672            human_bytes(used_kb * 1024),
1673            human_bytes(total_kb * 1024),
1674            mem_percent
1675        );
1676
1677        if cpu_load > 85 {
1678            out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1679        }
1680        if mem_percent > 90 {
1681            out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1682        }
1683
1684        Ok(out)
1685    }
1686    #[cfg(not(target_os = "windows"))]
1687    {
1688        Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1689    }
1690}
1691
1692#[derive(Debug)]
1693enum EndpointProbe {
1694    Reachable(u16),
1695    Unreachable(String),
1696}
1697
1698async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1699    let client = match reqwest::Client::builder()
1700        .timeout(std::time::Duration::from_secs(3))
1701        .build()
1702    {
1703        Ok(client) => client,
1704        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1705    };
1706
1707    match client.get(url).send().await {
1708        Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1709        Err(err) => EndpointProbe::Unreachable(err.to_string()),
1710    }
1711}
1712
1713async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1714    if configured_api.contains("11434") {
1715        let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1716        let url = format!("{}/api/ps", base);
1717        let client = reqwest::Client::builder()
1718            .timeout(std::time::Duration::from_secs(3))
1719            .build()
1720            .ok()?;
1721        let response = client.get(url).send().await.ok()?;
1722        let body = response.json::<serde_json::Value>().await.ok()?;
1723        let entries = body["models"].as_array()?;
1724        for entry in entries {
1725            let name = entry["name"]
1726                .as_str()
1727                .or_else(|| entry["model"].as_str())
1728                .unwrap_or_default();
1729            let lower = name.to_ascii_lowercase();
1730            if lower.contains("embed")
1731                || lower.contains("embedding")
1732                || lower.contains("minilm")
1733                || lower.contains("bge")
1734                || lower.contains("e5")
1735            {
1736                return Some(name.to_string());
1737            }
1738        }
1739        return None;
1740    }
1741
1742    let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1743    let url = format!("{}/api/v0/models", base);
1744    let client = reqwest::Client::builder()
1745        .timeout(std::time::Duration::from_secs(3))
1746        .build()
1747        .ok()?;
1748
1749    #[derive(serde::Deserialize)]
1750    struct ModelList {
1751        data: Vec<ModelEntry>,
1752    }
1753    #[derive(serde::Deserialize)]
1754    struct ModelEntry {
1755        id: String,
1756        #[serde(rename = "type", default)]
1757        model_type: String,
1758        #[serde(default)]
1759        state: String,
1760    }
1761
1762    let response = client.get(url).send().await.ok()?;
1763    let models = response.json::<ModelList>().await.ok()?;
1764    models
1765        .data
1766        .into_iter()
1767        .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1768        .map(|model| model.id)
1769}
1770
1771fn first_port_in_text(text: &str) -> Option<u16> {
1772    text.split(|c: char| !c.is_ascii_digit())
1773        .find(|fragment| !fragment.is_empty())
1774        .and_then(|fragment| fragment.parse::<u16>().ok())
1775}
1776
1777fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1778    let mut processes = collect_processes()?;
1779    if let Some(filter) = name_filter.as_deref() {
1780        let lowered = filter.to_ascii_lowercase();
1781        processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1782    }
1783    processes.sort_by(|a, b| {
1784        let a_cpu = a.cpu_percent.unwrap_or(0.0);
1785        let b_cpu = b.cpu_percent.unwrap_or(0.0);
1786        b_cpu
1787            .partial_cmp(&a_cpu)
1788            .unwrap_or(std::cmp::Ordering::Equal)
1789            .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1790            .then_with(|| a.name.cmp(&b.name))
1791            .then_with(|| a.pid.cmp(&b.pid))
1792    });
1793
1794    let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1795
1796    let mut out = String::from("Host inspection: processes\n\n");
1797    if let Some(filter) = name_filter.as_deref() {
1798        let _ = writeln!(out, "- Filter name: {}", filter);
1799    }
1800    let _ = writeln!(out, "- Processes found: {}", processes.len());
1801    let _ = writeln!(
1802        out,
1803        "- Total reported working set: {}",
1804        human_bytes(total_memory)
1805    );
1806
1807    if processes.is_empty() {
1808        out.push_str("\nNo running processes matched.");
1809        return Ok(out);
1810    }
1811
1812    out.push_str("\nTop processes by resource usage:\n");
1813    for entry in processes.iter().take(max_entries) {
1814        let cpu_str = entry
1815            .cpu_percent
1816            .map(|p| format!(" [CPU: {:.1}%]", p))
1817            .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1818            .unwrap_or_default();
1819        let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1820            format!(" [I/O R:{}/W:{}]", r, w)
1821        } else {
1822            " [I/O unknown]".to_string()
1823        };
1824        let _ = writeln!(
1825            out,
1826            "- {} (pid {}) - {}{}{}{}",
1827            entry.name,
1828            entry.pid,
1829            human_bytes(entry.memory_bytes),
1830            cpu_str,
1831            io_str,
1832            entry
1833                .detail
1834                .as_deref()
1835                .map(|detail| format!(" [{}]", detail))
1836                .unwrap_or_default()
1837        );
1838    }
1839    if processes.len() > max_entries {
1840        let _ = writeln!(
1841            out,
1842            "- ... {} more processes omitted",
1843            processes.len() - max_entries
1844        );
1845    }
1846
1847    Ok(out.trim_end().to_string())
1848}
1849
1850fn inspect_network(max_entries: usize) -> Result<String, String> {
1851    let adapters = collect_network_adapters()?;
1852    let active_count = adapters
1853        .iter()
1854        .filter(|adapter| adapter.is_active())
1855        .count();
1856    let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1857
1858    let mut out = String::from("Host inspection: network\n\n");
1859    let _ = writeln!(out, "- Adapters found: {}", adapters.len());
1860    let _ = writeln!(out, "- Active adapters: {}", active_count);
1861    let _ = writeln!(
1862        out,
1863        "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind",
1864        exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1865    );
1866
1867    if adapters.is_empty() {
1868        out.push_str("\nNo adapter details were detected.");
1869        return Ok(out);
1870    }
1871
1872    out.push_str("\nAdapter summary:\n");
1873    for adapter in adapters.iter().take(max_entries) {
1874        let status = if adapter.is_active() {
1875            "active"
1876        } else if adapter.disconnected {
1877            "disconnected"
1878        } else {
1879            "idle"
1880        };
1881        let mut details = vec![status.to_string()];
1882        if !adapter.ipv4.is_empty() {
1883            details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1884        }
1885        if !adapter.ipv6.is_empty() {
1886            details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1887        }
1888        if !adapter.gateways.is_empty() {
1889            details.push(format!("gateway {}", adapter.gateways.join(", ")));
1890        }
1891        if !adapter.dns_servers.is_empty() {
1892            details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1893        }
1894        let _ = writeln!(out, "- {} - {}", adapter.name, details.join(" | "));
1895    }
1896    if adapters.len() > max_entries {
1897        let _ = writeln!(
1898            out,
1899            "- ... {} more adapters omitted",
1900            adapters.len() - max_entries
1901        );
1902    }
1903
1904    Ok(out.trim_end().to_string())
1905}
1906
1907fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1908    let mut out = String::from("Host inspection: lan_discovery\n\n");
1909
1910    #[cfg(target_os = "windows")]
1911    {
1912        let n = max_entries.clamp(5, 20);
1913        let adapters = collect_network_adapters()?;
1914        let services = collect_services().unwrap_or_default();
1915        let active_adapters: Vec<&NetworkAdapter> = adapters
1916            .iter()
1917            .filter(|adapter| adapter.is_active())
1918            .collect();
1919        let gateways: Vec<String> = active_adapters
1920            .iter()
1921            .flat_map(|adapter| adapter.gateways.clone())
1922            .collect::<HashSet<_>>()
1923            .into_iter()
1924            .collect();
1925
1926        let neighbor_script = r#"
1927$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1928    Where-Object {
1929        $_.IPAddress -notlike '127.*' -and
1930        $_.IPAddress -notlike '169.254*' -and
1931        $_.State -notin @('Unreachable','Invalid')
1932    } |
1933    Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1934$neighbors | ConvertTo-Json -Compress
1935"#;
1936        let neighbor_text = Command::new("powershell")
1937            .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1938            .output()
1939            .ok()
1940            .and_then(|o| String::from_utf8(o.stdout).ok())
1941            .unwrap_or_default();
1942        let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1943            .into_iter()
1944            .take(n)
1945            .collect();
1946
1947        let listener_script = r#"
1948Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1949    Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1950    Select-Object LocalAddress, LocalPort, OwningProcess |
1951    ForEach-Object {
1952        $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1953        "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1954    }
1955"#;
1956        let listener_text = Command::new("powershell")
1957            .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1958            .output()
1959            .ok()
1960            .and_then(|o| String::from_utf8(o.stdout).ok())
1961            .unwrap_or_default();
1962        let listeners: Vec<(String, u16, String, String)> = listener_text
1963            .lines()
1964            .filter_map(|line| {
1965                let mut it = line.trim().splitn(4, '|');
1966                let a = it.next()?.to_string();
1967                let b = it.next()?.parse::<u16>().ok()?;
1968                let c = it.next()?.to_string();
1969                let d = it.next()?.to_string();
1970                Some((a, b, c, d))
1971            })
1972            .take(n)
1973            .collect();
1974
1975        let smb_mapping_script = r#"
1976Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1977    ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1978"#;
1979        let smb_mappings: Vec<String> = Command::new("powershell")
1980            .args([
1981                "-NoProfile",
1982                "-NonInteractive",
1983                "-Command",
1984                smb_mapping_script,
1985            ])
1986            .output()
1987            .ok()
1988            .and_then(|o| String::from_utf8(o.stdout).ok())
1989            .unwrap_or_default()
1990            .lines()
1991            .take(n)
1992            .map(|line| line.trim().to_string())
1993            .filter(|line| !line.is_empty())
1994            .collect();
1995
1996        let smb_connections_script = r#"
1997Get-SmbConnection -ErrorAction SilentlyContinue |
1998    Select-Object ServerName, ShareName, NumOpens |
1999    ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
2000"#;
2001        let smb_connections: Vec<String> = Command::new("powershell")
2002            .args([
2003                "-NoProfile",
2004                "-NonInteractive",
2005                "-Command",
2006                smb_connections_script,
2007            ])
2008            .output()
2009            .ok()
2010            .and_then(|o| String::from_utf8(o.stdout).ok())
2011            .unwrap_or_default()
2012            .lines()
2013            .take(n)
2014            .map(|line| line.trim().to_string())
2015            .filter(|line| !line.is_empty())
2016            .collect();
2017
2018        let discovery_service_names = [
2019            "FDResPub",
2020            "fdPHost",
2021            "SSDPSRV",
2022            "upnphost",
2023            "LanmanServer",
2024            "LanmanWorkstation",
2025            "lmhosts",
2026        ];
2027        let discovery_services: Vec<&ServiceEntry> = services
2028            .iter()
2029            .filter(|entry| {
2030                discovery_service_names
2031                    .iter()
2032                    .any(|name| entry.name.eq_ignore_ascii_case(name))
2033            })
2034            .collect();
2035
2036        let mut findings = Vec::with_capacity(4);
2037        if active_adapters.is_empty() {
2038            findings.push(AuditFinding {
2039                finding: "No active LAN adapters were detected.".to_string(),
2040                impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
2041                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(),
2042            });
2043        }
2044
2045        let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
2046            .iter()
2047            .copied()
2048            .filter(|entry| {
2049                !entry.status.eq_ignore_ascii_case("running")
2050                    && !entry.status.eq_ignore_ascii_case("active")
2051            })
2052            .collect();
2053        if !stopped_discovery_services.is_empty() {
2054            let names = {
2055                let mut s = String::new();
2056                for (i, entry) in stopped_discovery_services.iter().enumerate() {
2057                    if i > 0 {
2058                        s.push_str(", ");
2059                    }
2060                    s.push_str(&entry.name);
2061                }
2062                s
2063            };
2064            findings.push(AuditFinding {
2065                finding: format!("Discovery-related services are not running: {names}"),
2066                impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
2067                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(),
2068            });
2069        }
2070
2071        if listeners.is_empty() {
2072            findings.push(AuditFinding {
2073                finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2074                impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2075                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(),
2076            });
2077        }
2078
2079        if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2080            findings.push(AuditFinding {
2081                finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2082                impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2083                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(),
2084            });
2085        }
2086
2087        out.push_str("=== Findings ===\n");
2088        if findings.is_empty() {
2089            out.push_str(
2090                "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2091            );
2092            out.push_str("  Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2093            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");
2094        } else {
2095            for finding in &findings {
2096                let _ = writeln!(out, "- Finding: {}", finding.finding);
2097                let _ = writeln!(out, "  Impact: {}", finding.impact);
2098                let _ = writeln!(out, "  Fix: {}", finding.fix);
2099            }
2100        }
2101
2102        out.push_str("\n=== Active adapter and gateway summary ===\n");
2103        if active_adapters.is_empty() {
2104            out.push_str("- No active adapters detected.\n");
2105        } else {
2106            for adapter in active_adapters.iter().take(n) {
2107                let ipv4 = if adapter.ipv4.is_empty() {
2108                    "no IPv4".to_string()
2109                } else {
2110                    adapter.ipv4.join(", ")
2111                };
2112                let gateway = if adapter.gateways.is_empty() {
2113                    "no gateway".to_string()
2114                } else {
2115                    adapter.gateways.join(", ")
2116                };
2117                let _ = writeln!(
2118                    out,
2119                    "- {} | IPv4: {} | Gateway: {}",
2120                    adapter.name, ipv4, gateway
2121                );
2122            }
2123        }
2124
2125        out.push_str("\n=== Neighborhood evidence ===\n");
2126        let _ = writeln!(out, "- Gateway count: {}", gateways.len());
2127        let _ = writeln!(out, "- Neighbor entries observed: {}", neighbors.len());
2128        if neighbors.is_empty() {
2129            out.push_str("- No ARP/neighbor evidence retrieved.\n");
2130        } else {
2131            for (ip, mac, state, iface) in neighbors.iter().take(n) {
2132                let _ = writeln!(
2133                    out,
2134                    "- {} on {} | MAC: {} | State: {}",
2135                    ip, iface, mac, state
2136                );
2137            }
2138        }
2139
2140        out.push_str("\n=== Discovery services ===\n");
2141        if discovery_services.is_empty() {
2142            out.push_str("- Discovery service status unavailable.\n");
2143        } else {
2144            for entry in discovery_services.iter().take(n) {
2145                let startup = entry.startup.as_deref().unwrap_or("unknown");
2146                let _ = writeln!(
2147                    out,
2148                    "- {} | Status: {} | Startup: {}",
2149                    entry.name, entry.status, startup
2150                );
2151            }
2152        }
2153
2154        out.push_str("\n=== Discovery listener surface ===\n");
2155        if listeners.is_empty() {
2156            out.push_str("- No discovery-oriented UDP listeners detected.\n");
2157        } else {
2158            for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2159                let label = match *port {
2160                    137 => "NetBIOS Name Service",
2161                    138 => "NetBIOS Datagram",
2162                    1900 => "SSDP/UPnP",
2163                    5353 => "mDNS",
2164                    5355 => "LLMNR",
2165                    _ => "Discovery",
2166                };
2167                let proc_label = if proc_name.is_empty() {
2168                    "unknown".to_string()
2169                } else {
2170                    proc_name.clone()
2171                };
2172                let _ = writeln!(
2173                    out,
2174                    "- {}:{} | {} | PID {} ({})",
2175                    addr, port, label, pid, proc_label
2176                );
2177            }
2178        }
2179
2180        out.push_str("\n=== SMB and neighborhood visibility ===\n");
2181        if smb_mappings.is_empty() && smb_connections.is_empty() {
2182            out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2183        } else {
2184            if !smb_mappings.is_empty() {
2185                out.push_str("- Mapped drives:\n");
2186                for mapping in smb_mappings.iter().take(n) {
2187                    let mut it = mapping.splitn(3, '|');
2188                    if let (Some(a), Some(b)) = (it.next(), it.next()) {
2189                        let _ = writeln!(out, "  - {} -> {}", a, b);
2190                    }
2191                }
2192            }
2193            if !smb_connections.is_empty() {
2194                out.push_str("- Active SMB connections:\n");
2195                for connection in smb_connections.iter().take(n) {
2196                    let mut it = connection.splitn(4, '|');
2197                    if let (Some(a), Some(b), Some(c)) = (it.next(), it.next(), it.next()) {
2198                        let _ = writeln!(out, "  - {}\\{} | Opens: {}", a, b, c);
2199                    }
2200                }
2201            }
2202        }
2203    }
2204
2205    #[cfg(not(target_os = "windows"))]
2206    {
2207        let n = max_entries.clamp(5, 20);
2208        let adapters = collect_network_adapters()?;
2209        let arp_output = Command::new("ip")
2210            .args(["neigh"])
2211            .output()
2212            .ok()
2213            .and_then(|o| String::from_utf8(o.stdout).ok())
2214            .unwrap_or_default();
2215        let neighbors: Vec<&str> = arp_output
2216            .lines()
2217            .filter(|line| !line.trim().is_empty())
2218            .take(n)
2219            .collect();
2220
2221        out.push_str("=== Findings ===\n");
2222        if adapters.iter().any(|adapter| adapter.is_active()) {
2223            out.push_str(
2224                "- Finding: LAN discovery support is partially available on this platform.\n",
2225            );
2226            out.push_str("  Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2227            out.push_str("  Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2228        } else {
2229            out.push_str("- Finding: No active LAN adapters were detected.\n");
2230            out.push_str(
2231                "  Impact: Neighborhood discovery cannot work without an active interface.\n",
2232            );
2233            out.push_str("  Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2234        }
2235
2236        out.push_str("\n=== Active adapter and gateway summary ===\n");
2237        if adapters.is_empty() {
2238            out.push_str("- No adapters detected.\n");
2239        } else {
2240            for adapter in adapters.iter().take(n) {
2241                let ipv4 = if adapter.ipv4.is_empty() {
2242                    "no IPv4".to_string()
2243                } else {
2244                    adapter.ipv4.join(", ")
2245                };
2246                let gateway = if adapter.gateways.is_empty() {
2247                    "no gateway".to_string()
2248                } else {
2249                    adapter.gateways.join(", ")
2250                };
2251                let _ = write!(
2252                    out,
2253                    "- {} | IPv4: {} | Gateway: {}\n",
2254                    adapter.name, ipv4, gateway
2255                );
2256            }
2257        }
2258
2259        out.push_str("\n=== Neighborhood evidence ===\n");
2260        if neighbors.is_empty() {
2261            out.push_str("- No neighbor entries detected.\n");
2262        } else {
2263            for line in neighbors {
2264                let _ = write!(out, "- {}\n", line.trim());
2265            }
2266        }
2267    }
2268
2269    Ok(out.trim_end().to_string())
2270}
2271
2272fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2273    let mut services = collect_services()?;
2274    if let Some(filter) = name_filter.as_deref() {
2275        let lowered = filter.to_ascii_lowercase();
2276        services.retain(|entry| {
2277            entry.name.to_ascii_lowercase().contains(&lowered)
2278                || entry
2279                    .display_name
2280                    .as_deref()
2281                    .map(|d| d.to_ascii_lowercase().contains(&lowered))
2282                    .unwrap_or(false)
2283        });
2284    }
2285
2286    services.sort_by(|a, b| {
2287        let a_running =
2288            a.status.eq_ignore_ascii_case("running") || a.status.eq_ignore_ascii_case("active");
2289        let b_running =
2290            b.status.eq_ignore_ascii_case("running") || b.status.eq_ignore_ascii_case("active");
2291        b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2292    });
2293
2294    let running = services
2295        .iter()
2296        .filter(|entry| {
2297            entry.status.eq_ignore_ascii_case("running")
2298                || entry.status.eq_ignore_ascii_case("active")
2299        })
2300        .count();
2301    let failed = services
2302        .iter()
2303        .filter(|entry| {
2304            entry.status.eq_ignore_ascii_case("failed")
2305                || entry.status.eq_ignore_ascii_case("error")
2306                || entry.status.eq_ignore_ascii_case("stopped")
2307        })
2308        .count();
2309
2310    let mut out = String::from("Host inspection: services\n\n");
2311    if let Some(filter) = name_filter.as_deref() {
2312        let _ = writeln!(out, "- Filter name: {}", filter);
2313    }
2314    let _ = writeln!(out, "- Services found: {}", services.len());
2315    let _ = writeln!(out, "- Running/active: {}", running);
2316    let _ = writeln!(out, "- Failed/stopped: {}", failed);
2317
2318    if services.is_empty() {
2319        out.push_str("\nNo services matched.");
2320        return Ok(out);
2321    }
2322
2323    // Split into running and stopped sections so both are always visible.
2324    let per_section = (max_entries / 2).max(5);
2325
2326    let running_services: Vec<_> = services
2327        .iter()
2328        .filter(|e| {
2329            e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2330        })
2331        .collect();
2332    let stopped_services: Vec<_> = services
2333        .iter()
2334        .filter(|e| {
2335            e.status.eq_ignore_ascii_case("stopped")
2336                || e.status.eq_ignore_ascii_case("failed")
2337                || e.status.eq_ignore_ascii_case("error")
2338        })
2339        .collect();
2340
2341    let fmt_entry = |entry: &&ServiceEntry| {
2342        let startup = entry
2343            .startup
2344            .as_deref()
2345            .map(|v| format!(" | startup {}", v))
2346            .unwrap_or_default();
2347        let logon = entry
2348            .start_name
2349            .as_deref()
2350            .map(|v| format!(" | LogOn: {}", v))
2351            .unwrap_or_default();
2352        let display = entry
2353            .display_name
2354            .as_deref()
2355            .filter(|v| *v != entry.name)
2356            .map(|v| format!(" [{}]", v))
2357            .unwrap_or_default();
2358        format!(
2359            "- {}{} - {}{}{}\n",
2360            entry.name, display, entry.status, startup, logon
2361        )
2362    };
2363
2364    let _ = write!(
2365        out,
2366        "\nRunning services ({} total, showing up to {}):\n",
2367        running_services.len(),
2368        per_section
2369    );
2370    for entry in running_services.iter().take(per_section) {
2371        out.push_str(&fmt_entry(entry));
2372    }
2373    if running_services.len() > per_section {
2374        let _ = writeln!(
2375            out,
2376            "- ... {} more running services omitted",
2377            running_services.len() - per_section
2378        );
2379    }
2380
2381    let _ = write!(
2382        out,
2383        "\nStopped/failed services ({} total, showing up to {}):\n",
2384        stopped_services.len(),
2385        per_section
2386    );
2387    for entry in stopped_services.iter().take(per_section) {
2388        out.push_str(&fmt_entry(entry));
2389    }
2390    if stopped_services.len() > per_section {
2391        let _ = writeln!(
2392            out,
2393            "- ... {} more stopped services omitted",
2394            stopped_services.len() - per_section
2395        );
2396    }
2397
2398    Ok(out.trim_end().to_string())
2399}
2400
2401async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2402    inspect_directory("Disk", path, max_entries).await
2403}
2404
2405fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2406    let mut listeners = collect_listening_ports()?;
2407    if let Some(port) = port_filter {
2408        listeners.retain(|entry| entry.port == port);
2409    }
2410    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2411
2412    let mut out = String::from("Host inspection: ports\n\n");
2413    if let Some(port) = port_filter {
2414        let _ = writeln!(out, "- Filter port: {}", port);
2415    }
2416    let _ = writeln!(out, "- Listening endpoints found: {}", listeners.len());
2417
2418    if listeners.is_empty() {
2419        out.push_str("\nNo listening endpoints matched.");
2420        return Ok(out);
2421    }
2422
2423    out.push_str("\nListening endpoints:\n");
2424    for entry in listeners.iter().take(max_entries) {
2425        let pid_str = entry
2426            .pid
2427            .as_deref()
2428            .map(|p| format!(" pid {}", p))
2429            .unwrap_or_default();
2430        let name_str = entry
2431            .process_name
2432            .as_deref()
2433            .map(|n| format!(" [{}]", n))
2434            .unwrap_or_default();
2435        let _ = writeln!(
2436            out,
2437            "- {} {} ({}){}{}",
2438            entry.protocol, entry.local, entry.state, pid_str, name_str
2439        );
2440    }
2441    if listeners.len() > max_entries {
2442        let _ = writeln!(
2443            out,
2444            "- ... {} more listening endpoints omitted",
2445            listeners.len() - max_entries
2446        );
2447    }
2448
2449    Ok(out.trim_end().to_string())
2450}
2451
2452fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2453    if !path.exists() {
2454        return Err(format!("Path does not exist: {}", path.display()));
2455    }
2456    if !path.is_dir() {
2457        return Err(format!("Path is not a directory: {}", path.display()));
2458    }
2459
2460    let markers = collect_project_markers(&path);
2461    let hematite_state = collect_hematite_state(&path);
2462    let git_state = inspect_git_state(&path);
2463    let release_state = inspect_release_artifacts(&path);
2464
2465    let mut out = String::from("Host inspection: repo_doctor\n\n");
2466    let _ = writeln!(out, "- Path: {}", path.display());
2467    let _ = writeln!(out, "- Workspace mode: {}", workspace_mode_for_path(&path));
2468
2469    if markers.is_empty() {
2470        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");
2471    } else {
2472        out.push_str("- Project markers:\n");
2473        for marker in markers.iter().take(max_entries) {
2474            let _ = writeln!(out, "  - {}", marker);
2475        }
2476    }
2477
2478    match git_state {
2479        Some(git) => {
2480            let _ = writeln!(out, "- Git root: {}", git.root.display());
2481            let _ = writeln!(out, "- Git branch: {}", git.branch);
2482            let _ = writeln!(out, "- Git status: {}", git.status_label());
2483        }
2484        None => out.push_str("- Git: not inside a detected work tree\n"),
2485    }
2486
2487    let _ = writeln!(
2488        out,
2489        "- Hematite docs/imports/reports: {}/{}/{}",
2490        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2491    );
2492    if hematite_state.workspace_profile {
2493        out.push_str("- Workspace profile: present\n");
2494    } else {
2495        out.push_str("- Workspace profile: absent\n");
2496    }
2497
2498    if let Some(release) = release_state {
2499        let _ = writeln!(out, "- Cargo version: {}", release.version);
2500        let _ = writeln!(
2501            out,
2502            "- Windows artifacts for current version: {}/{}/{}",
2503            bool_label(release.portable_dir),
2504            bool_label(release.portable_zip),
2505            bool_label(release.setup_exe)
2506        );
2507    }
2508
2509    Ok(out.trim_end().to_string())
2510}
2511
2512async fn inspect_known_directory(
2513    label: &str,
2514    path: Option<PathBuf>,
2515    max_entries: usize,
2516) -> Result<String, String> {
2517    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2518    inspect_directory(label, path, max_entries).await
2519}
2520
2521async fn inspect_directory(
2522    label: &str,
2523    path: PathBuf,
2524    max_entries: usize,
2525) -> Result<String, String> {
2526    let label = label.to_string();
2527    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2528        .await
2529        .map_err(|e| format!("inspect_host task failed: {e}"))?
2530}
2531
2532fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2533    if !path.exists() {
2534        return Err(format!("Path does not exist: {}", path.display()));
2535    }
2536    if !path.is_dir() {
2537        return Err(format!("Path is not a directory: {}", path.display()));
2538    }
2539
2540    let mut top_level_entries = Vec::new();
2541    for entry in fs::read_dir(path)
2542        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2543    {
2544        match entry {
2545            Ok(entry) => top_level_entries.push(entry),
2546            Err(_) => continue,
2547        }
2548    }
2549    top_level_entries.sort_by_key(|entry| entry.file_name());
2550
2551    let top_level_count = top_level_entries.len();
2552    let mut sample_names = Vec::with_capacity(max_entries.min(top_level_count));
2553    let mut largest_entries = Vec::with_capacity(top_level_count);
2554    let mut aggregate = PathAggregate::default();
2555    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2556
2557    for entry in top_level_entries {
2558        let name = entry.file_name().to_string_lossy().to_string();
2559        if sample_names.len() < max_entries {
2560            sample_names.push(name.clone());
2561        }
2562        let kind = match entry.file_type() {
2563            Ok(ft) if ft.is_dir() => "dir",
2564            Ok(ft) if ft.is_symlink() => "symlink",
2565            _ => "file",
2566        };
2567        let stats = measure_path(&entry.path(), &mut budget);
2568        aggregate.merge(&stats);
2569        largest_entries.push(LargestEntry {
2570            name,
2571            kind,
2572            bytes: stats.total_bytes,
2573        });
2574    }
2575
2576    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2577
2578    let mut out = format!("Directory inspection: {}\n\n", label);
2579    let _ = writeln!(out, "- Path: {}", path.display());
2580    let _ = writeln!(out, "- Top-level items: {}", top_level_count);
2581    let _ = writeln!(out, "- Recursive files: {}", aggregate.file_count);
2582    let _ = writeln!(out, "- Recursive directories: {}", aggregate.dir_count);
2583    let _ = writeln!(
2584        out,
2585        "- Total size: {}{}",
2586        human_bytes(aggregate.total_bytes),
2587        if aggregate.partial {
2588            " (partial scan)"
2589        } else {
2590            ""
2591        }
2592    );
2593    if aggregate.skipped_entries > 0 {
2594        let _ = writeln!(
2595            out,
2596            "- Skipped entries: {} (permissions, symlinks, or scan budget)",
2597            aggregate.skipped_entries
2598        );
2599    }
2600
2601    if !largest_entries.is_empty() {
2602        out.push_str("\nLargest top-level entries:\n");
2603        for entry in largest_entries.iter().take(max_entries) {
2604            let _ = writeln!(
2605                out,
2606                "- {} [{}] - {}",
2607                entry.name,
2608                entry.kind,
2609                human_bytes(entry.bytes)
2610            );
2611        }
2612    }
2613
2614    if !sample_names.is_empty() {
2615        out.push_str("\nSample names:\n");
2616        for name in sample_names {
2617            let _ = writeln!(out, "- {}", name);
2618        }
2619    }
2620
2621    Ok(out.trim_end().to_string())
2622}
2623
2624fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2625    let trimmed = raw.trim();
2626    if trimmed.is_empty() {
2627        return Err("Path must not be empty.".to_string());
2628    }
2629
2630    if let Some(rest) = trimmed
2631        .strip_prefix("~/")
2632        .or_else(|| trimmed.strip_prefix("~\\"))
2633    {
2634        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2635        return Ok(home.join(rest));
2636    }
2637
2638    let path = PathBuf::from(trimmed);
2639    if path.is_absolute() {
2640        Ok(path)
2641    } else {
2642        let cwd =
2643            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2644        let full_path = cwd.join(&path);
2645
2646        // Heuristic: If it's a relative path to .hematite or hematite.exe and doesn't exist here,
2647        // check the user's home directory.
2648        if !full_path.exists()
2649            && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2650        {
2651            if let Some(home) = home::home_dir() {
2652                let home_path = home.join(trimmed);
2653                if home_path.exists() {
2654                    return Ok(home_path);
2655                }
2656            }
2657        }
2658
2659        Ok(full_path)
2660    }
2661}
2662
2663fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2664    workspace_mode_for_path(workspace_root)
2665}
2666
2667fn workspace_mode_for_path(path: &Path) -> &'static str {
2668    if is_project_marker_path(path) {
2669        "project"
2670    } else if path.join(".hematite").join("docs").exists()
2671        || path.join(".hematite").join("imports").exists()
2672        || path.join(".hematite").join("reports").exists()
2673    {
2674        "docs-only"
2675    } else {
2676        "general directory"
2677    }
2678}
2679
2680fn is_project_marker_path(path: &Path) -> bool {
2681    [
2682        "Cargo.toml",
2683        "package.json",
2684        "pyproject.toml",
2685        "go.mod",
2686        "composer.json",
2687        "requirements.txt",
2688        "Makefile",
2689        "justfile",
2690    ]
2691    .iter()
2692    .any(|name| path.join(name).exists())
2693        || path.join(".git").exists()
2694}
2695
2696fn preferred_shell_label() -> &'static str {
2697    #[cfg(target_os = "windows")]
2698    {
2699        "PowerShell"
2700    }
2701    #[cfg(not(target_os = "windows"))]
2702    {
2703        "sh"
2704    }
2705}
2706
2707fn desktop_dir() -> Option<PathBuf> {
2708    home::home_dir().map(|home| home.join("Desktop"))
2709}
2710
2711fn downloads_dir() -> Option<PathBuf> {
2712    home::home_dir().map(|home| home.join("Downloads"))
2713}
2714
2715fn count_top_level_items(path: &Path) -> Result<usize, String> {
2716    let mut count = 0usize;
2717    for entry in
2718        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2719    {
2720        if entry.is_ok() {
2721            count += 1;
2722        }
2723    }
2724    Ok(count)
2725}
2726
2727#[derive(Default)]
2728struct PathAggregate {
2729    total_bytes: u64,
2730    file_count: u64,
2731    dir_count: u64,
2732    skipped_entries: u64,
2733    partial: bool,
2734}
2735
2736impl PathAggregate {
2737    fn merge(&mut self, other: &PathAggregate) {
2738        self.total_bytes += other.total_bytes;
2739        self.file_count += other.file_count;
2740        self.dir_count += other.dir_count;
2741        self.skipped_entries += other.skipped_entries;
2742        self.partial |= other.partial;
2743    }
2744}
2745
2746struct LargestEntry {
2747    name: String,
2748    kind: &'static str,
2749    bytes: u64,
2750}
2751
2752fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2753    if *budget == 0 {
2754        return PathAggregate {
2755            partial: true,
2756            skipped_entries: 1,
2757            ..PathAggregate::default()
2758        };
2759    }
2760    *budget -= 1;
2761
2762    let metadata = match fs::symlink_metadata(path) {
2763        Ok(metadata) => metadata,
2764        Err(_) => {
2765            return PathAggregate {
2766                skipped_entries: 1,
2767                ..PathAggregate::default()
2768            }
2769        }
2770    };
2771
2772    let file_type = metadata.file_type();
2773    if file_type.is_symlink() {
2774        return PathAggregate {
2775            skipped_entries: 1,
2776            ..PathAggregate::default()
2777        };
2778    }
2779
2780    if metadata.is_file() {
2781        return PathAggregate {
2782            total_bytes: metadata.len(),
2783            file_count: 1,
2784            ..PathAggregate::default()
2785        };
2786    }
2787
2788    if !metadata.is_dir() {
2789        return PathAggregate::default();
2790    }
2791
2792    let mut aggregate = PathAggregate {
2793        dir_count: 1,
2794        ..PathAggregate::default()
2795    };
2796
2797    let read_dir = match fs::read_dir(path) {
2798        Ok(read_dir) => read_dir,
2799        Err(_) => {
2800            aggregate.skipped_entries += 1;
2801            return aggregate;
2802        }
2803    };
2804
2805    for child in read_dir {
2806        match child {
2807            Ok(child) => {
2808                let child_stats = measure_path(&child.path(), budget);
2809                aggregate.merge(&child_stats);
2810            }
2811            Err(_) => aggregate.skipped_entries += 1,
2812        }
2813    }
2814
2815    aggregate
2816}
2817
2818struct PathAnalysis {
2819    total_entries: usize,
2820    unique_entries: usize,
2821    entries: Vec<String>,
2822    duplicate_entries: Vec<String>,
2823    missing_entries: Vec<String>,
2824}
2825
2826fn analyze_path_env() -> PathAnalysis {
2827    let mut entries = Vec::new();
2828    let mut duplicate_entries = Vec::new();
2829    let mut missing_entries = Vec::new();
2830    let mut seen = HashSet::new();
2831
2832    let raw_path = std::env::var_os("PATH").unwrap_or_default();
2833    for path in std::env::split_paths(&raw_path) {
2834        let display = path.display().to_string();
2835        if display.trim().is_empty() {
2836            continue;
2837        }
2838
2839        let normalized = normalize_path_entry(&display);
2840        if !seen.insert(normalized) {
2841            duplicate_entries.push(display.clone());
2842        }
2843        if !path.exists() {
2844            missing_entries.push(display.clone());
2845        }
2846        entries.push(display);
2847    }
2848
2849    let total_entries = entries.len();
2850    let unique_entries = seen.len();
2851
2852    PathAnalysis {
2853        total_entries,
2854        unique_entries,
2855        entries,
2856        duplicate_entries,
2857        missing_entries,
2858    }
2859}
2860
2861fn normalize_path_entry(value: &str) -> String {
2862    #[cfg(target_os = "windows")]
2863    {
2864        value
2865            .replace('/', "\\")
2866            .trim_end_matches(['\\', '/'])
2867            .to_ascii_lowercase()
2868    }
2869    #[cfg(not(target_os = "windows"))]
2870    {
2871        value.trim_end_matches('/').to_string()
2872    }
2873}
2874
2875struct ToolchainReport {
2876    found: Vec<(String, String)>,
2877    missing: Vec<String>,
2878}
2879
2880struct PackageManagerReport {
2881    found: Vec<(String, String)>,
2882}
2883
2884#[derive(Debug, Clone)]
2885struct ProcessEntry {
2886    name: String,
2887    pid: u32,
2888    memory_bytes: u64,
2889    cpu_seconds: Option<f64>,
2890    cpu_percent: Option<f64>,
2891    read_ops: Option<u64>,
2892    write_ops: Option<u64>,
2893    detail: Option<String>,
2894}
2895
2896#[derive(Debug, Clone)]
2897struct ServiceEntry {
2898    name: String,
2899    status: String,
2900    startup: Option<String>,
2901    display_name: Option<String>,
2902    start_name: Option<String>,
2903}
2904
2905#[derive(Debug, Clone, Default)]
2906struct NetworkAdapter {
2907    name: String,
2908    ipv4: Vec<String>,
2909    ipv6: Vec<String>,
2910    gateways: Vec<String>,
2911    dns_servers: Vec<String>,
2912    disconnected: bool,
2913}
2914
2915impl NetworkAdapter {
2916    fn is_active(&self) -> bool {
2917        !self.disconnected
2918            && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2919    }
2920}
2921
2922#[derive(Debug, Clone, Copy, Default)]
2923struct ListenerExposureSummary {
2924    loopback_only: usize,
2925    wildcard_public: usize,
2926    specific_bind: usize,
2927}
2928
2929#[derive(Debug, Clone)]
2930struct ListeningPort {
2931    protocol: String,
2932    local: String,
2933    port: u16,
2934    state: String,
2935    pid: Option<String>,
2936    process_name: Option<String>,
2937}
2938
2939fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2940    #[cfg(target_os = "windows")]
2941    {
2942        collect_windows_listening_ports()
2943    }
2944    #[cfg(not(target_os = "windows"))]
2945    {
2946        collect_unix_listening_ports()
2947    }
2948}
2949
2950fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2951    #[cfg(target_os = "windows")]
2952    {
2953        collect_windows_network_adapters()
2954    }
2955    #[cfg(not(target_os = "windows"))]
2956    {
2957        collect_unix_network_adapters()
2958    }
2959}
2960
2961fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2962    #[cfg(target_os = "windows")]
2963    {
2964        collect_windows_services()
2965    }
2966    #[cfg(not(target_os = "windows"))]
2967    {
2968        collect_unix_services()
2969    }
2970}
2971
2972#[cfg(target_os = "windows")]
2973fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2974    let output = Command::new("netstat")
2975        .args(["-ano", "-p", "tcp"])
2976        .output()
2977        .map_err(|e| format!("Failed to run netstat: {e}"))?;
2978    if !output.status.success() {
2979        return Err("netstat returned a non-success status.".to_string());
2980    }
2981
2982    let text = String::from_utf8_lossy(&output.stdout);
2983    let mut listeners = Vec::new();
2984    for line in text.lines() {
2985        let trimmed = line.trim();
2986        if !trimmed.starts_with("TCP") {
2987            continue;
2988        }
2989        let mut it = trimmed.split_whitespace();
2990        if let (Some(proto), Some(local), Some(_), Some(state), Some(pid)) =
2991            (it.next(), it.next(), it.next(), it.next(), it.next())
2992        {
2993            if state != "LISTENING" {
2994                continue;
2995            }
2996            let Some(port) = extract_port_from_socket(local) else {
2997                continue;
2998            };
2999            listeners.push(ListeningPort {
3000                protocol: proto.to_string(),
3001                local: local.to_string(),
3002                port,
3003                state: state.to_string(),
3004                pid: Some(pid.to_string()),
3005                process_name: None,
3006            });
3007        }
3008    }
3009
3010    // Enrich with process names via PowerShell — works without elevation for
3011    // most user-space processes. System processes (PID 4, etc.) stay unnamed.
3012    let unique_pids: Vec<String> = listeners
3013        .iter()
3014        .filter_map(|l| l.pid.clone())
3015        .collect::<HashSet<_>>()
3016        .into_iter()
3017        .collect();
3018
3019    if !unique_pids.is_empty() {
3020        let pid_list = unique_pids.join(",");
3021        let ps_cmd = format!(
3022            "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
3023            pid_list
3024        );
3025        if let Ok(ps_out) = Command::new("powershell")
3026            .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
3027            .output()
3028        {
3029            let mut pid_map = std::collections::HashMap::<String, String>::new();
3030            let ps_text = String::from_utf8_lossy(&ps_out.stdout);
3031            for line in ps_text.lines() {
3032                let mut it = line.split_whitespace();
3033                if let (Some(a), Some(b)) = (it.next(), it.next()) {
3034                    pid_map.insert(a.to_string(), b.to_string());
3035                }
3036            }
3037            for listener in &mut listeners {
3038                if let Some(pid) = &listener.pid {
3039                    listener.process_name = pid_map.get(pid).cloned();
3040                }
3041            }
3042        }
3043    }
3044
3045    Ok(listeners)
3046}
3047
3048#[cfg(not(target_os = "windows"))]
3049fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
3050    let output = Command::new("ss")
3051        .args(["-ltn"])
3052        .output()
3053        .map_err(|e| format!("Failed to run ss: {e}"))?;
3054    if !output.status.success() {
3055        return Err("ss returned a non-success status.".to_string());
3056    }
3057
3058    let text = String::from_utf8_lossy(&output.stdout);
3059    let mut listeners = Vec::new();
3060    for line in text.lines().skip(1) {
3061        let mut it = line.split_whitespace();
3062        if let (Some(state), Some(_), Some(_), Some(local)) =
3063            (it.next(), it.next(), it.next(), it.next())
3064        {
3065            let Some(port) = extract_port_from_socket(local) else {
3066                continue;
3067            };
3068            listeners.push(ListeningPort {
3069                protocol: "tcp".to_string(),
3070                local: local.to_string(),
3071                port,
3072                state: state.to_string(),
3073                pid: None,
3074                process_name: None,
3075            });
3076        }
3077    }
3078
3079    Ok(listeners)
3080}
3081
3082fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3083    #[cfg(target_os = "windows")]
3084    {
3085        collect_windows_processes()
3086    }
3087    #[cfg(not(target_os = "windows"))]
3088    {
3089        collect_unix_processes()
3090    }
3091}
3092
3093#[cfg(target_os = "windows")]
3094fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3095    let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3096    let output = Command::new("powershell")
3097        .args(["-NoProfile", "-Command", command])
3098        .output()
3099        .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3100    if !output.status.success() {
3101        return Err("PowerShell service inspection returned a non-success status.".to_string());
3102    }
3103
3104    parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3105}
3106
3107#[cfg(not(target_os = "windows"))]
3108fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3109    let status_output = Command::new("systemctl")
3110        .args([
3111            "list-units",
3112            "--type=service",
3113            "--all",
3114            "--no-pager",
3115            "--no-legend",
3116            "--plain",
3117        ])
3118        .output()
3119        .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3120    if !status_output.status.success() {
3121        return Err("systemctl list-units returned a non-success status.".to_string());
3122    }
3123
3124    let startup_output = Command::new("systemctl")
3125        .args([
3126            "list-unit-files",
3127            "--type=service",
3128            "--no-legend",
3129            "--no-pager",
3130            "--plain",
3131        ])
3132        .output()
3133        .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3134    if !startup_output.status.success() {
3135        return Err("systemctl list-unit-files returned a non-success status.".to_string());
3136    }
3137
3138    Ok(parse_unix_services(
3139        &String::from_utf8_lossy(&status_output.stdout),
3140        &String::from_utf8_lossy(&startup_output.stdout),
3141    ))
3142}
3143
3144#[cfg(target_os = "windows")]
3145fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3146    let output = Command::new("ipconfig")
3147        .args(["/all"])
3148        .output()
3149        .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3150    if !output.status.success() {
3151        return Err("ipconfig returned a non-success status.".to_string());
3152    }
3153
3154    Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3155        &output.stdout,
3156    )))
3157}
3158
3159#[cfg(not(target_os = "windows"))]
3160fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3161    let addr_output = Command::new("ip")
3162        .args(["-o", "addr", "show", "up"])
3163        .output()
3164        .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3165    if !addr_output.status.success() {
3166        return Err("ip addr returned a non-success status.".to_string());
3167    }
3168
3169    let route_output = Command::new("ip")
3170        .args(["route", "show", "default"])
3171        .output()
3172        .map_err(|e| format!("Failed to run ip route: {e}"))?;
3173    if !route_output.status.success() {
3174        return Err("ip route returned a non-success status.".to_string());
3175    }
3176
3177    let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3178    apply_unix_default_routes(
3179        &mut adapters,
3180        &String::from_utf8_lossy(&route_output.stdout),
3181    );
3182    apply_unix_dns_servers(&mut adapters);
3183    Ok(adapters)
3184}
3185
3186#[cfg(target_os = "windows")]
3187fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3188    // We take two samples of CPU time separated by a short interval to calculate recent CPU %
3189    let script = r#"
3190        $s1 = Get-Process | Select-Object Id, CPU
3191        Start-Sleep -Milliseconds 250
3192        $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3193        $s2 | ForEach-Object {
3194            $p2 = $_
3195            $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3196            $pct = 0.0
3197            if ($p1 -and $p2.CPU -gt $p1.CPU) {
3198                # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3199                # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3200                # Standard Task Manager style is (delta / interval) * 100.
3201                $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3202            }
3203            "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3204        }
3205    "#;
3206
3207    let output = Command::new("powershell")
3208        .args(["-NoProfile", "-Command", script])
3209        .output()
3210        .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3211
3212    let text = String::from_utf8_lossy(&output.stdout);
3213    let mut out = Vec::new();
3214    let mut parts: Vec<&str> = Vec::with_capacity(8);
3215    for line in text.lines() {
3216        parts.clear();
3217        parts.extend(line.trim().split('|'));
3218        if parts.len() < 5 {
3219            continue;
3220        }
3221        let mut entry = ProcessEntry {
3222            name: "unknown".to_string(),
3223            pid: 0,
3224            memory_bytes: 0,
3225            cpu_seconds: None,
3226            cpu_percent: None,
3227            read_ops: None,
3228            write_ops: None,
3229            detail: None,
3230        };
3231        for p in &parts {
3232            if let Some((k, v)) = p.split_once(':') {
3233                match k {
3234                    "PID" => entry.pid = v.parse().unwrap_or(0),
3235                    "NAME" => entry.name = v.to_string(),
3236                    "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3237                    "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3238                    "CPU_P" => entry.cpu_percent = v.parse().ok(),
3239                    "READ" => entry.read_ops = v.parse().ok(),
3240                    "WRITE" => entry.write_ops = v.parse().ok(),
3241                    _ => {}
3242                }
3243            }
3244        }
3245        out.push(entry);
3246    }
3247    Ok(out)
3248}
3249
3250#[cfg(not(target_os = "windows"))]
3251fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3252    let output = Command::new("ps")
3253        .args(["-eo", "pid=,rss=,comm="])
3254        .output()
3255        .map_err(|e| format!("Failed to run ps: {e}"))?;
3256    if !output.status.success() {
3257        return Err("ps returned a non-success status.".to_string());
3258    }
3259
3260    let text = String::from_utf8_lossy(&output.stdout);
3261    let mut processes = Vec::new();
3262    for line in text.lines() {
3263        let mut it = line.split_whitespace();
3264        let Some(pid_str) = it.next() else {
3265            continue;
3266        };
3267        let Some(rss_str) = it.next() else {
3268            continue;
3269        };
3270        let Some(first_word) = it.next() else {
3271            continue;
3272        };
3273        let Ok(pid) = pid_str.parse::<u32>() else {
3274            continue;
3275        };
3276        let Ok(rss_kib) = rss_str.parse::<u64>() else {
3277            continue;
3278        };
3279        let mut name = first_word.to_string();
3280        for w in it {
3281            name.push(' ');
3282            name.push_str(w);
3283        }
3284        processes.push(ProcessEntry {
3285            name,
3286            pid,
3287            memory_bytes: rss_kib * 1024,
3288            cpu_seconds: None,
3289            cpu_percent: None,
3290            read_ops: None,
3291            write_ops: None,
3292            detail: None,
3293        });
3294    }
3295
3296    Ok(processes)
3297}
3298
3299fn extract_port_from_socket(value: &str) -> Option<u16> {
3300    let cleaned = value.trim().trim_matches(['[', ']']);
3301    let port_str = cleaned.rsplit(':').next()?;
3302    port_str.parse::<u16>().ok()
3303}
3304
3305fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3306    let mut summary = ListenerExposureSummary::default();
3307    for entry in listeners {
3308        let local = entry.local.to_ascii_lowercase();
3309        if is_loopback_listener(&local) {
3310            summary.loopback_only += 1;
3311        } else if is_wildcard_listener(&local) {
3312            summary.wildcard_public += 1;
3313        } else {
3314            summary.specific_bind += 1;
3315        }
3316    }
3317    summary
3318}
3319
3320fn is_loopback_listener(local: &str) -> bool {
3321    local.starts_with("127.")
3322        || local.starts_with("[::1]")
3323        || local.starts_with("::1")
3324        || local.starts_with("localhost:")
3325}
3326
3327fn is_wildcard_listener(local: &str) -> bool {
3328    local.starts_with("0.0.0.0:")
3329        || local.starts_with("[::]:")
3330        || local.starts_with(":::")
3331        || local == "*:*"
3332}
3333
3334struct GitState {
3335    root: PathBuf,
3336    branch: String,
3337    dirty_entries: usize,
3338}
3339
3340impl GitState {
3341    fn status_label(&self) -> String {
3342        if self.dirty_entries == 0 {
3343            "clean".to_string()
3344        } else {
3345            format!("dirty ({} changed path(s))", self.dirty_entries)
3346        }
3347    }
3348}
3349
3350fn inspect_git_state(path: &Path) -> Option<GitState> {
3351    let root = capture_first_line(
3352        "git",
3353        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3354    )?;
3355    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3356        .unwrap_or_else(|| "detached".to_string());
3357    let output = Command::new("git")
3358        .args(["-C", path.to_str()?, "status", "--short"])
3359        .output()
3360        .ok()?;
3361    if !output.status.success() {
3362        return None;
3363    }
3364    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3365    Some(GitState {
3366        root: PathBuf::from(root),
3367        branch,
3368        dirty_entries,
3369    })
3370}
3371
3372struct HematiteState {
3373    docs_count: usize,
3374    import_count: usize,
3375    report_count: usize,
3376    workspace_profile: bool,
3377}
3378
3379fn collect_hematite_state(path: &Path) -> HematiteState {
3380    let root = path.join(".hematite");
3381    HematiteState {
3382        docs_count: count_entries_if_exists(&root.join("docs")),
3383        import_count: count_entries_if_exists(&root.join("imports")),
3384        report_count: count_entries_if_exists(&root.join("reports")),
3385        workspace_profile: root.join("workspace_profile.json").exists(),
3386    }
3387}
3388
3389fn count_entries_if_exists(path: &Path) -> usize {
3390    if !path.exists() || !path.is_dir() {
3391        return 0;
3392    }
3393    fs::read_dir(path)
3394        .ok()
3395        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3396        .unwrap_or(0)
3397}
3398
3399fn collect_project_markers(path: &Path) -> Vec<String> {
3400    [
3401        "Cargo.toml",
3402        "package.json",
3403        "pyproject.toml",
3404        "go.mod",
3405        "justfile",
3406        "Makefile",
3407        ".git",
3408    ]
3409    .iter()
3410    .filter(|&name| path.join(name).exists())
3411    .map(|name| (*name).to_string())
3412    .collect()
3413}
3414
3415struct ReleaseArtifactState {
3416    version: String,
3417    portable_dir: bool,
3418    portable_zip: bool,
3419    setup_exe: bool,
3420}
3421
3422fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3423    let cargo_toml = path.join("Cargo.toml");
3424    if !cargo_toml.exists() {
3425        return None;
3426    }
3427    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3428    let version = [regex_line_capture(
3429        &cargo_text,
3430        r#"(?m)^version\s*=\s*"([^"]+)""#,
3431    )?]
3432    .concat();
3433    let dist_windows = path.join("dist").join("windows");
3434    let prefix = format!("Hematite-{}", version);
3435    Some(ReleaseArtifactState {
3436        version,
3437        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3438        portable_zip: dist_windows
3439            .join(format!("{}-portable.zip", prefix))
3440            .exists(),
3441        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3442    })
3443}
3444
3445fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3446    let regex = regex::Regex::new(pattern).ok()?;
3447    let captures = regex.captures(text)?;
3448    captures.get(1).map(|m| m.as_str().to_string())
3449}
3450
3451fn bool_label(value: bool) -> &'static str {
3452    if value {
3453        "yes"
3454    } else {
3455        "no"
3456    }
3457}
3458
3459fn collect_toolchains() -> ToolchainReport {
3460    let config = crate::agent::config::load_config();
3461    let mut python_probes = Vec::with_capacity(5);
3462    if let Some(ref path) = config.python_path {
3463        python_probes.push(CommandProbe::new(path, &["--version"]));
3464    };
3465
3466    python_probes.extend([
3467        CommandProbe::new("python3", &["--version"]),
3468        CommandProbe::new("python", &["--version"]),
3469        CommandProbe::new("py", &["-3", "--version"]),
3470        CommandProbe::new("py", &["--version"]),
3471    ]);
3472
3473    let checks = [
3474        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3475        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3476        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3477        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3478        ToolCheck::new(
3479            "npm",
3480            &[
3481                CommandProbe::new("npm", &["--version"]),
3482                CommandProbe::new("npm.cmd", &["--version"]),
3483            ],
3484        ),
3485        ToolCheck::new(
3486            "pnpm",
3487            &[
3488                CommandProbe::new("pnpm", &["--version"]),
3489                CommandProbe::new("pnpm.cmd", &["--version"]),
3490            ],
3491        ),
3492        ToolCheck::new("python", &python_probes),
3493        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3494        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3495        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3496        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3497    ];
3498
3499    let mut found = Vec::with_capacity(checks.len());
3500    let mut missing = Vec::with_capacity(checks.len());
3501
3502    for check in checks {
3503        match check.detect() {
3504            Some(version) => found.push((check.label.to_string(), version)),
3505            None => missing.push(check.label.to_string()),
3506        }
3507    }
3508
3509    ToolchainReport { found, missing }
3510}
3511
3512fn collect_package_managers() -> PackageManagerReport {
3513    let config = crate::agent::config::load_config();
3514    let mut pip_probes = Vec::with_capacity(6);
3515    if let Some(ref path) = config.python_path {
3516        pip_probes.push(CommandProbe::new(path, &["-m", "pip", "--version"]));
3517    }
3518    pip_probes.extend([
3519        CommandProbe::new("python3", &["-m", "pip", "--version"]),
3520        CommandProbe::new("python", &["-m", "pip", "--version"]),
3521        CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3522        CommandProbe::new("py", &["-m", "pip", "--version"]),
3523        CommandProbe::new("pip", &["--version"]),
3524    ]);
3525
3526    let checks = [
3527        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3528        ToolCheck::new(
3529            "npm",
3530            &[
3531                CommandProbe::new("npm", &["--version"]),
3532                CommandProbe::new("npm.cmd", &["--version"]),
3533            ],
3534        ),
3535        ToolCheck::new(
3536            "pnpm",
3537            &[
3538                CommandProbe::new("pnpm", &["--version"]),
3539                CommandProbe::new("pnpm.cmd", &["--version"]),
3540            ],
3541        ),
3542        ToolCheck::new("pip", &pip_probes),
3543        ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3544        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3545        ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3546        ToolCheck::new(
3547            "choco",
3548            &[
3549                CommandProbe::new("choco", &["--version"]),
3550                CommandProbe::new("choco.exe", &["--version"]),
3551            ],
3552        ),
3553        ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3554    ];
3555
3556    let mut found = Vec::with_capacity(checks.len());
3557    for check in checks {
3558        if let Some(version) = check.detect() {
3559            found.push((check.label.to_string(), version))
3560        }
3561    }
3562
3563    PackageManagerReport { found }
3564}
3565
3566#[derive(Clone)]
3567struct ToolCheck {
3568    label: &'static str,
3569    probes: Vec<CommandProbe>,
3570}
3571
3572impl ToolCheck {
3573    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3574        Self {
3575            label,
3576            probes: probes.to_vec(),
3577        }
3578    }
3579
3580    fn detect(&self) -> Option<String> {
3581        for probe in &self.probes {
3582            if let Some(output) = capture_first_line(&probe.program, &probe.args) {
3583                return Some(output);
3584            }
3585        }
3586        None
3587    }
3588}
3589
3590#[derive(Clone)]
3591struct CommandProbe {
3592    program: String,
3593    args: Vec<String>,
3594}
3595
3596impl CommandProbe {
3597    fn new(program: &str, args: &[&str]) -> Self {
3598        Self {
3599            program: program.to_string(),
3600            args: args.iter().map(|s| s.to_string()).collect(),
3601        }
3602    }
3603}
3604
3605fn build_env_doctor_findings(
3606    toolchains: &ToolchainReport,
3607    package_managers: &PackageManagerReport,
3608    path_stats: &PathAnalysis,
3609) -> Vec<String> {
3610    let found_tools = toolchains
3611        .found
3612        .iter()
3613        .map(|(label, _)| label.as_str())
3614        .collect::<HashSet<_>>();
3615    let found_managers = package_managers
3616        .found
3617        .iter()
3618        .map(|(label, _)| label.as_str())
3619        .collect::<HashSet<_>>();
3620
3621    let mut findings = Vec::with_capacity(4);
3622
3623    if !path_stats.duplicate_entries.is_empty() {
3624        findings.push(format!(
3625            "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3626            path_stats.duplicate_entries.len()
3627        ));
3628    }
3629    if !path_stats.missing_entries.is_empty() {
3630        findings.push(format!(
3631            "PATH contains {} entries that do not exist on disk.",
3632            path_stats.missing_entries.len()
3633        ));
3634    }
3635    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3636        findings.push(
3637            "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3638                .to_string(),
3639        );
3640    }
3641    if found_tools.contains("node")
3642        && !found_managers.contains("npm")
3643        && !found_managers.contains("pnpm")
3644    {
3645        findings.push(
3646            "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3647                .to_string(),
3648        );
3649    }
3650    if found_tools.contains("python")
3651        && !found_managers.contains("pip")
3652        && !found_managers.contains("uv")
3653        && !found_managers.contains("pipx")
3654    {
3655        findings.push(
3656            "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3657                .to_string(),
3658        );
3659    }
3660    let windows_manager_count = ["winget", "choco", "scoop"]
3661        .iter()
3662        .filter(|label| found_managers.contains(**label))
3663        .count();
3664    if windows_manager_count > 1 {
3665        findings.push(
3666            "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3667                .to_string(),
3668        );
3669    }
3670    if findings.is_empty() && !found_managers.is_empty() {
3671        findings.push(
3672            "Core package-manager coverage looks healthy for a normal developer workstation."
3673                .to_string(),
3674        );
3675    }
3676
3677    findings
3678}
3679
3680fn capture_first_line<S: AsRef<str>>(program: &str, args: &[S]) -> Option<String> {
3681    let output = std::process::Command::new(program)
3682        .args(args.iter().map(|s| s.as_ref()))
3683        .output()
3684        .ok()?;
3685    if !output.status.success() {
3686        return None;
3687    }
3688
3689    let stdout = if output.stdout.is_empty() {
3690        String::from_utf8_lossy(&output.stderr).into_owned()
3691    } else {
3692        String::from_utf8_lossy(&output.stdout).into_owned()
3693    };
3694
3695    stdout
3696        .lines()
3697        .map(str::trim)
3698        .find(|line| !line.is_empty())
3699        .map(|line| line.to_string())
3700}
3701
3702fn human_bytes(bytes: u64) -> String {
3703    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3704    let mut value = bytes as f64;
3705    let mut unit_index = 0usize;
3706
3707    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3708        value /= 1024.0;
3709        unit_index += 1;
3710    }
3711
3712    if unit_index == 0 {
3713        format!("{} {}", bytes, UNITS[unit_index])
3714    } else {
3715        format!("{value:.1} {}", UNITS[unit_index])
3716    }
3717}
3718
3719#[cfg(target_os = "windows")]
3720fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3721    let mut adapters = Vec::new();
3722    let mut current: Option<NetworkAdapter> = None;
3723    let mut pending_dns = false;
3724
3725    for raw_line in text.lines() {
3726        let line = raw_line.trim_end();
3727        let trimmed = line.trim();
3728        if trimmed.is_empty() {
3729            pending_dns = false;
3730            continue;
3731        }
3732
3733        if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3734            if let Some(adapter) = current.take() {
3735                adapters.push(adapter);
3736            }
3737            current = Some(NetworkAdapter {
3738                name: trimmed.trim_end_matches(':').to_string(),
3739                ..NetworkAdapter::default()
3740            });
3741            pending_dns = false;
3742            continue;
3743        }
3744
3745        let Some(adapter) = current.as_mut() else {
3746            continue;
3747        };
3748
3749        if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3750            adapter.disconnected = true;
3751        }
3752
3753        if let Some(value) = value_after_colon(trimmed) {
3754            let normalized = normalize_ipconfig_value(value);
3755            if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3756                adapter.ipv4.push(normalized);
3757                pending_dns = false;
3758            } else if trimmed.starts_with("IPv6 Address")
3759                || trimmed.starts_with("Temporary IPv6 Address")
3760                || trimmed.starts_with("Link-local IPv6 Address")
3761            {
3762                if !normalized.is_empty() {
3763                    adapter.ipv6.push(normalized);
3764                }
3765                pending_dns = false;
3766            } else if trimmed.starts_with("Default Gateway") {
3767                if !normalized.is_empty() {
3768                    adapter.gateways.push(normalized);
3769                }
3770                pending_dns = false;
3771            } else if trimmed.starts_with("DNS Servers") {
3772                if !normalized.is_empty() {
3773                    adapter.dns_servers.push(normalized);
3774                }
3775                pending_dns = true;
3776            } else {
3777                pending_dns = false;
3778            }
3779        } else if pending_dns {
3780            let normalized = normalize_ipconfig_value(trimmed);
3781            if !normalized.is_empty() {
3782                adapter.dns_servers.push(normalized);
3783            }
3784        }
3785    }
3786
3787    if let Some(adapter) = current.take() {
3788        adapters.push(adapter);
3789    }
3790
3791    for adapter in &mut adapters {
3792        dedup_vec(&mut adapter.ipv4);
3793        dedup_vec(&mut adapter.ipv6);
3794        dedup_vec(&mut adapter.gateways);
3795        dedup_vec(&mut adapter.dns_servers);
3796    }
3797
3798    adapters
3799}
3800
3801#[cfg(not(target_os = "windows"))]
3802fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3803    let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3804
3805    for line in text.lines() {
3806        let mut it = line.split_whitespace();
3807        let (Some(_), Some(iface), Some(family), Some(addr_full)) =
3808            (it.next(), it.next(), it.next(), it.next())
3809        else {
3810            continue;
3811        };
3812        let name = iface.trim_end_matches(':').to_string();
3813        let addr = addr_full.split('/').next().unwrap_or("").to_string();
3814        let entry = adapters
3815            .entry(name.clone())
3816            .or_insert_with(|| NetworkAdapter {
3817                name,
3818                ..NetworkAdapter::default()
3819            });
3820        match family {
3821            "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3822            "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3823            _ => {}
3824        }
3825    }
3826
3827    adapters.into_values().collect()
3828}
3829
3830#[cfg(not(target_os = "windows"))]
3831fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3832    for line in text.lines() {
3833        let cols: Vec<&str> = line.split_whitespace().collect();
3834        if cols.len() < 5 {
3835            continue;
3836        }
3837        let gateway = cols
3838            .windows(2)
3839            .find(|pair| pair[0] == "via")
3840            .map(|pair| pair[1].to_string());
3841        let dev = cols
3842            .windows(2)
3843            .find(|pair| pair[0] == "dev")
3844            .map(|pair| pair[1]);
3845        if let (Some(gateway), Some(dev)) = (gateway, dev) {
3846            if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3847                adapter.gateways.push(gateway);
3848            }
3849        }
3850    }
3851
3852    for adapter in adapters {
3853        dedup_vec(&mut adapter.gateways);
3854    }
3855}
3856
3857#[cfg(not(target_os = "windows"))]
3858fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3859    let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3860        return;
3861    };
3862    let mut dns_servers = text
3863        .lines()
3864        .filter_map(|line| line.strip_prefix("nameserver "))
3865        .map(str::trim)
3866        .filter(|value| !value.is_empty())
3867        .map(|value| value.to_string())
3868        .collect::<Vec<_>>();
3869    dedup_vec(&mut dns_servers);
3870    if dns_servers.is_empty() {
3871        return;
3872    }
3873    for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3874        adapter.dns_servers = dns_servers.clone();
3875    }
3876}
3877
3878#[cfg(target_os = "windows")]
3879fn value_after_colon(line: &str) -> Option<&str> {
3880    line.split_once(':').map(|(_, value)| value.trim())
3881}
3882
3883#[cfg(target_os = "windows")]
3884fn normalize_ipconfig_value(value: &str) -> String {
3885    value
3886        .trim()
3887        .trim_end_matches("(Preferred)")
3888        .trim_end_matches("(Deprecated)")
3889        .trim()
3890        .trim_matches(['(', ')'])
3891        .trim()
3892        .to_string()
3893}
3894
3895#[cfg(target_os = "windows")]
3896fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3897    let mac_upper = mac.to_ascii_uppercase();
3898    if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3899        return true;
3900    }
3901
3902    ip == "255.255.255.255"
3903        || ip.starts_with("224.")
3904        || ip.starts_with("225.")
3905        || ip.starts_with("226.")
3906        || ip.starts_with("227.")
3907        || ip.starts_with("228.")
3908        || ip.starts_with("229.")
3909        || ip.starts_with("230.")
3910        || ip.starts_with("231.")
3911        || ip.starts_with("232.")
3912        || ip.starts_with("233.")
3913        || ip.starts_with("234.")
3914        || ip.starts_with("235.")
3915        || ip.starts_with("236.")
3916        || ip.starts_with("237.")
3917        || ip.starts_with("238.")
3918        || ip.starts_with("239.")
3919}
3920
3921fn dedup_vec(values: &mut Vec<String>) {
3922    let mut seen = HashSet::new();
3923    values.retain(|value| seen.insert(value.clone()));
3924}
3925
3926#[cfg(target_os = "windows")]
3927fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3928    let trimmed = text.trim();
3929    if trimmed.is_empty() {
3930        return Vec::new();
3931    }
3932
3933    let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3934        return Vec::new();
3935    };
3936    let entries = match value {
3937        Value::Array(items) => items,
3938        other => vec![other],
3939    };
3940
3941    let mut neighbors = Vec::with_capacity(entries.len());
3942    for entry in entries {
3943        let ip = entry
3944            .get("IPAddress")
3945            .and_then(|v| v.as_str())
3946            .unwrap_or("")
3947            .to_string();
3948        if ip.is_empty() {
3949            continue;
3950        }
3951        let mac = entry
3952            .get("LinkLayerAddress")
3953            .and_then(|v| v.as_str())
3954            .unwrap_or("unknown")
3955            .to_string();
3956        let state = entry
3957            .get("State")
3958            .and_then(|v| v.as_str())
3959            .unwrap_or("unknown")
3960            .to_string();
3961        let iface = entry
3962            .get("InterfaceAlias")
3963            .and_then(|v| v.as_str())
3964            .unwrap_or("unknown")
3965            .to_string();
3966        if is_noise_lan_neighbor(&ip, &mac) {
3967            continue;
3968        }
3969        neighbors.push((ip, mac, state, iface));
3970    }
3971
3972    neighbors
3973}
3974
3975#[cfg(target_os = "windows")]
3976fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3977    let trimmed = text.trim();
3978    if trimmed.is_empty() {
3979        return Ok(Vec::new());
3980    }
3981
3982    let value: Value = serde_json::from_str(trimmed)
3983        .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3984    let entries = match value {
3985        Value::Array(items) => items,
3986        other => vec![other],
3987    };
3988
3989    let mut services = Vec::with_capacity(entries.len());
3990    for entry in entries {
3991        let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3992            continue;
3993        };
3994        services.push(ServiceEntry {
3995            name: name.to_string(),
3996            status: entry
3997                .get("State")
3998                .and_then(|v| v.as_str())
3999                .unwrap_or("unknown")
4000                .to_string(),
4001            startup: entry
4002                .get("StartMode")
4003                .and_then(|v| v.as_str())
4004                .map(|v| v.to_string()),
4005            display_name: entry
4006                .get("DisplayName")
4007                .and_then(|v| v.as_str())
4008                .map(|v| v.to_string()),
4009            start_name: entry
4010                .get("StartName")
4011                .and_then(|v| v.as_str())
4012                .map(|v| v.to_string()),
4013        });
4014    }
4015
4016    Ok(services)
4017}
4018
4019#[cfg(target_os = "windows")]
4020fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
4021    match node.cloned() {
4022        Some(Value::Array(items)) => items,
4023        Some(other) => vec![other],
4024        None => Vec::new(),
4025    }
4026}
4027
4028#[cfg(target_os = "windows")]
4029fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
4030    windows_json_entries(node)
4031        .into_iter()
4032        .filter_map(|entry| {
4033            let name = entry
4034                .get("FriendlyName")
4035                .and_then(|v| v.as_str())
4036                .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
4037                .unwrap_or("")
4038                .trim()
4039                .to_string();
4040            if name.is_empty() {
4041                return None;
4042            }
4043            Some(WindowsPnpDevice {
4044                name,
4045                status: entry
4046                    .get("Status")
4047                    .and_then(|v| v.as_str())
4048                    .unwrap_or("Unknown")
4049                    .trim()
4050                    .to_string(),
4051                problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
4052                    entry
4053                        .get("Problem")
4054                        .and_then(|v| v.as_i64())
4055                        .map(|v| v as u64)
4056                }),
4057                class_name: entry
4058                    .get("Class")
4059                    .and_then(|v| v.as_str())
4060                    .map(|v| v.trim().to_string()),
4061                instance_id: entry
4062                    .get("InstanceId")
4063                    .and_then(|v| v.as_str())
4064                    .map(|v| v.trim().to_string()),
4065            })
4066        })
4067        .collect()
4068}
4069
4070#[cfg(target_os = "windows")]
4071fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
4072    windows_json_entries(node)
4073        .into_iter()
4074        .filter_map(|entry| {
4075            let name = entry
4076                .get("Name")
4077                .and_then(|v| v.as_str())
4078                .unwrap_or("")
4079                .trim()
4080                .to_string();
4081            if name.is_empty() {
4082                return None;
4083            }
4084            Some(WindowsSoundDevice {
4085                name,
4086                status: entry
4087                    .get("Status")
4088                    .and_then(|v| v.as_str())
4089                    .unwrap_or("Unknown")
4090                    .trim()
4091                    .to_string(),
4092                manufacturer: entry
4093                    .get("Manufacturer")
4094                    .and_then(|v| v.as_str())
4095                    .map(|v| v.trim().to_string()),
4096            })
4097        })
4098        .collect()
4099}
4100
4101#[cfg(target_os = "windows")]
4102fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
4103    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4104        || device.problem.unwrap_or(0) != 0
4105}
4106
4107#[cfg(target_os = "windows")]
4108fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4109    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4110}
4111
4112#[cfg(target_os = "windows")]
4113fn is_microphone_like_name(name: &str) -> bool {
4114    let lower = name.to_ascii_lowercase();
4115    lower.contains("microphone")
4116        || lower.contains("mic")
4117        || lower.contains("input")
4118        || lower.contains("array")
4119        || lower.contains("capture")
4120        || lower.contains("record")
4121}
4122
4123#[cfg(target_os = "windows")]
4124fn is_bluetooth_like_name(name: &str) -> bool {
4125    let lower = name.to_ascii_lowercase();
4126    lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4127}
4128
4129#[cfg(target_os = "windows")]
4130fn service_is_running(service: &ServiceEntry) -> bool {
4131    service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4132}
4133
4134#[cfg(not(target_os = "windows"))]
4135fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4136    let mut startup_modes = std::collections::HashMap::<String, String>::new();
4137    for line in startup_text.lines() {
4138        let mut it = line.split_whitespace();
4139        if let (Some(name), Some(mode)) = (it.next(), it.next()) {
4140            startup_modes.insert(name.to_string(), mode.to_string());
4141        }
4142    }
4143
4144    let mut services = Vec::new();
4145    for line in status_text.lines() {
4146        let mut it = line.split_whitespace();
4147        let Some(unit) = it.next() else {
4148            continue;
4149        };
4150        let Some(load) = it.next() else {
4151            continue;
4152        };
4153        let Some(active) = it.next() else {
4154            continue;
4155        };
4156        let Some(sub) = it.next() else {
4157            continue;
4158        };
4159        let description = {
4160            let mut desc = String::new();
4161            for (i, w) in it.enumerate() {
4162                if i > 0 {
4163                    desc.push(' ');
4164                }
4165                desc.push_str(w);
4166            }
4167            if desc.is_empty() {
4168                None
4169            } else {
4170                Some(desc)
4171            }
4172        };
4173        services.push(ServiceEntry {
4174            name: unit.to_string(),
4175            status: format!("{}/{}", active, sub),
4176            startup: startup_modes
4177                .get(unit)
4178                .cloned()
4179                .or_else(|| Some(load.to_string())),
4180            display_name: description,
4181            start_name: None,
4182        });
4183    }
4184
4185    services
4186}
4187
4188// ── health_report ─────────────────────────────────────────────────────────────
4189
4190/// Synthesized system health report — runs multiple checks and returns a
4191/// plain-English tiered verdict suitable for both developers and non-technical
4192/// users who just want to know if their machine is okay.
4193fn inspect_health_report() -> Result<String, String> {
4194    let mut needs_fix: Vec<String> = Vec::with_capacity(8);
4195    let mut watch: Vec<String> = Vec::with_capacity(8);
4196    let mut good: Vec<String> = Vec::with_capacity(8);
4197    let mut tips: Vec<String> = Vec::with_capacity(8);
4198
4199    health_check_disk(&mut needs_fix, &mut watch, &mut good);
4200    health_check_memory(&mut watch, &mut good);
4201    health_check_network(&mut needs_fix, &mut watch, &mut good);
4202    health_check_pending_reboot(&mut watch, &mut good);
4203    health_check_services(&mut needs_fix, &mut watch, &mut good);
4204    health_check_thermal(&mut watch, &mut good);
4205    health_check_tools(&mut watch, &mut good, &mut tips);
4206    health_check_recent_errors(&mut watch, &mut tips);
4207
4208    let overall = if !needs_fix.is_empty() {
4209        "ACTION REQUIRED"
4210    } else if !watch.is_empty() {
4211        "WORTH A LOOK"
4212    } else {
4213        "ALL GOOD"
4214    };
4215
4216    let mut out = format!("System Health Report — {overall}\n\n");
4217
4218    if !needs_fix.is_empty() {
4219        out.push_str("Needs fixing:\n");
4220        for item in &needs_fix {
4221            let _ = writeln!(out, "  [!] {item}");
4222        }
4223        out.push('\n');
4224    }
4225    if !watch.is_empty() {
4226        out.push_str("Worth watching:\n");
4227        for item in &watch {
4228            let _ = writeln!(out, "  [-] {item}");
4229        }
4230        out.push('\n');
4231    }
4232    if !good.is_empty() {
4233        out.push_str("Looking good:\n");
4234        for item in &good {
4235            let _ = writeln!(out, "  [+] {item}");
4236        }
4237        out.push('\n');
4238    }
4239    if !tips.is_empty() {
4240        out.push_str("To dig deeper:\n");
4241        for tip in &tips {
4242            let _ = writeln!(out, "  {tip}");
4243        }
4244    }
4245
4246    Ok(out.trim_end().to_string())
4247}
4248
4249fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4250    #[cfg(target_os = "windows")]
4251    {
4252        let script = r#"try {
4253    $d = Get-PSDrive C -ErrorAction Stop
4254    "$($d.Free)|$($d.Used)"
4255} catch { "ERR" }"#;
4256        if let Ok(out) = Command::new("powershell")
4257            .args(["-NoProfile", "-Command", script])
4258            .output()
4259        {
4260            let text = String::from_utf8_lossy(&out.stdout);
4261            let text = text.trim();
4262            if !text.starts_with("ERR") {
4263                let mut it = text.splitn(3, '|');
4264                if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
4265                    let free_bytes: u64 = p0.trim().parse().unwrap_or(0);
4266                    let used_bytes: u64 = p1.trim().parse().unwrap_or(0);
4267                    let total = free_bytes + used_bytes;
4268                    let free_gb = free_bytes / 1_073_741_824;
4269                    let pct_free = if total > 0 {
4270                        (free_bytes as f64 / total as f64 * 100.0) as u64
4271                    } else {
4272                        0
4273                    };
4274                    let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4275                    if free_gb < 5 {
4276                        needs_fix.push(format!(
4277                            "{msg} — very low. Free up space or your system may slow down or stop working."
4278                        ));
4279                    } else if free_gb < 15 {
4280                        watch.push(format!("{msg} — getting low, consider cleaning up."));
4281                    } else {
4282                        good.push(msg);
4283                    }
4284                    return;
4285                }
4286            }
4287        }
4288        watch.push("Disk: could not read free space from C: drive.".to_string());
4289    }
4290
4291    #[cfg(not(target_os = "windows"))]
4292    {
4293        if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4294            let text = String::from_utf8_lossy(&out.stdout);
4295            for line in text.lines().skip(1) {
4296                let mut it = line.split_whitespace();
4297                if let (Some(_), Some(_), Some(_), Some(avail_raw), Some(use_pct_raw)) =
4298                    (it.next(), it.next(), it.next(), it.next(), it.next())
4299                {
4300                    let avail_str = avail_raw.trim_end_matches('G');
4301                    let use_pct = use_pct_raw.trim_end_matches('%');
4302                    let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4303                    let used_pct: u64 = use_pct.parse().unwrap_or(0);
4304                    let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4305                    if avail_gb < 5 {
4306                        needs_fix.push(format!(
4307                            "{msg} — very low. Free up space to prevent system issues."
4308                        ));
4309                    } else if avail_gb < 15 {
4310                        watch.push(format!("{msg} — getting low."));
4311                    } else {
4312                        good.push(msg);
4313                    }
4314                    return;
4315                }
4316            }
4317        }
4318        watch.push("Disk: could not determine free space.".to_string());
4319    }
4320}
4321
4322fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4323    #[cfg(target_os = "windows")]
4324    {
4325        let script = r#"try {
4326    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4327    "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4328} catch { "ERR" }"#;
4329        if let Ok(out) = Command::new("powershell")
4330            .args(["-NoProfile", "-Command", script])
4331            .output()
4332        {
4333            let text = String::from_utf8_lossy(&out.stdout);
4334            let text = text.trim();
4335            if !text.starts_with("ERR") {
4336                let mut it = text.splitn(3, '|');
4337                if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
4338                    let free_kb: u64 = p0.trim().parse().unwrap_or(0);
4339                    let total_kb: u64 = p1.trim().parse().unwrap_or(0);
4340                    if total_kb > 0 {
4341                        let free_gb = free_kb / 1_048_576;
4342                        let total_gb = total_kb / 1_048_576;
4343                        let free_pct = free_kb * 100 / total_kb;
4344                        let msg = format!(
4345                            "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4346                        );
4347                        if free_pct < 10 {
4348                            watch.push(format!(
4349                                "{msg} — very low. Close unused apps to free up memory."
4350                            ));
4351                        } else if free_pct < 25 {
4352                            watch.push(format!("{msg} — running a bit low."));
4353                        } else {
4354                            good.push(msg);
4355                        }
4356                    }
4357                }
4358            }
4359        }
4360    }
4361
4362    #[cfg(not(target_os = "windows"))]
4363    {
4364        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4365            let mut total_kb = 0u64;
4366            let mut avail_kb = 0u64;
4367            for line in content.lines() {
4368                if line.starts_with("MemTotal:") {
4369                    total_kb = line
4370                        .split_whitespace()
4371                        .nth(1)
4372                        .and_then(|v| v.parse().ok())
4373                        .unwrap_or(0);
4374                } else if line.starts_with("MemAvailable:") {
4375                    avail_kb = line
4376                        .split_whitespace()
4377                        .nth(1)
4378                        .and_then(|v| v.parse().ok())
4379                        .unwrap_or(0);
4380                }
4381            }
4382            if total_kb > 0 {
4383                let free_gb = avail_kb / 1_048_576;
4384                let total_gb = total_kb / 1_048_576;
4385                let free_pct = avail_kb * 100 / total_kb;
4386                let msg =
4387                    format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4388                if free_pct < 10 {
4389                    watch.push(format!("{msg} — very low. Close unused apps."));
4390                } else if free_pct < 25 {
4391                    watch.push(format!("{msg} — running a bit low."));
4392                } else {
4393                    good.push(msg);
4394                }
4395            }
4396        }
4397    }
4398}
4399
4400/// Try running `cmd --arg` via PATH first, then via a known install-path fallback.
4401/// Prevents false "not installed" reports when the process PATH omits tool directories
4402/// (e.g. ~/.cargo/bin missing from a shortcut-launched or headless session).
4403fn probe_tool(cmd: &str, arg: &str) -> bool {
4404    if Command::new(cmd)
4405        .arg(arg)
4406        .stdout(std::process::Stdio::null())
4407        .stderr(std::process::Stdio::null())
4408        .status()
4409        .map(|s| s.success())
4410        .unwrap_or(false)
4411    {
4412        return true;
4413    }
4414    // Fallback: well-known Windows install locations for tools that live outside system32.
4415    #[cfg(windows)]
4416    {
4417        let home = std::env::var("USERPROFILE").unwrap_or_default();
4418        let fallback: Option<String> = match cmd {
4419            "cargo" | "rustc" | "rustup" => Some(format!(r"{}\\.cargo\\bin\\{}.exe", home, cmd)),
4420            "node" => Some(r"C:\Program Files\nodejs\node.exe".to_string()),
4421            "npm" => Some("C:\\Program Files\\nodejs\\npm.cmd".to_string()),
4422            _ => None,
4423        };
4424        if let Some(path) = fallback {
4425            return Command::new(&path)
4426                .arg(arg)
4427                .stdout(std::process::Stdio::null())
4428                .stderr(std::process::Stdio::null())
4429                .status()
4430                .map(|s| s.success())
4431                .unwrap_or(false);
4432        }
4433    }
4434    false
4435}
4436
4437fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4438    let tool_checks: &[(&str, &str, &str)] = &[
4439        ("git", "--version", "Git"),
4440        ("cargo", "--version", "Rust / Cargo"),
4441        ("node", "--version", "Node.js"),
4442        ("python", "--version", "Python"),
4443        ("python3", "--version", "Python 3"),
4444        ("npm", "--version", "npm"),
4445    ];
4446
4447    let mut found: Vec<String> = Vec::with_capacity(tool_checks.len());
4448    let mut missing: Vec<String> = Vec::with_capacity(tool_checks.len());
4449    let mut python_found = false;
4450
4451    for (cmd, arg, label) in tool_checks {
4452        if cmd.starts_with("python") && python_found {
4453            continue;
4454        }
4455        let ok = probe_tool(cmd, arg);
4456        if ok {
4457            found.push((*label).to_string());
4458            if cmd.starts_with("python") {
4459                python_found = true;
4460            }
4461        } else if !cmd.starts_with("python") || !python_found {
4462            missing.push((*label).to_string());
4463        }
4464    }
4465
4466    if !found.is_empty() {
4467        good.push(format!("Dev tools found: {}", found.join(", ")));
4468    }
4469    if !missing.is_empty() {
4470        watch.push(format!(
4471            "Not installed (or not on PATH): {} — only matters if you need them",
4472            missing.join(", ")
4473        ));
4474        tips.push(
4475            "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4476                .to_string(),
4477        );
4478    }
4479}
4480
4481fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4482    #[cfg(target_os = "windows")]
4483    {
4484        let script = r#"try {
4485    $cutoff = (Get-Date).AddHours(-24)
4486    $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4487    $count
4488} catch { "0" }"#;
4489        if let Ok(out) = Command::new("powershell")
4490            .args(["-NoProfile", "-Command", script])
4491            .output()
4492        {
4493            let text = String::from_utf8_lossy(&out.stdout);
4494            let count: u64 = text.trim().parse().unwrap_or(0);
4495            if count > 0 {
4496                watch.push(format!(
4497                    "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4498                    if count == 1 { "" } else { "s" }
4499                ));
4500                tips.push(
4501                    "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4502                        .to_string(),
4503                );
4504            }
4505        }
4506    }
4507
4508    #[cfg(not(target_os = "windows"))]
4509    {
4510        if let Ok(out) = Command::new("journalctl")
4511            .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4512            .output()
4513        {
4514            let text = String::from_utf8_lossy(&out.stdout);
4515            if !text.trim().is_empty() {
4516                watch.push("Critical/error entries found in the system journal.".to_string());
4517                tips.push(
4518                    "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4519                );
4520            }
4521        }
4522    }
4523}
4524
4525fn health_check_network(
4526    needs_fix: &mut Vec<String>,
4527    watch: &mut Vec<String>,
4528    good: &mut Vec<String>,
4529) {
4530    #[cfg(target_os = "windows")]
4531    {
4532        // Use .NET Ping directly — PS5.1 compatible, 2-second timeout.
4533        let script = r#"try {
4534    $ping = New-Object System.Net.NetworkInformation.Ping
4535    $r = $ping.Send("1.1.1.1", 2000)
4536    if ($r.Status -eq 'Success') { "OK|$($r.RoundtripTime)" } else { "FAIL" }
4537} catch { "FAIL" }"#;
4538        if let Ok(out) = Command::new("powershell")
4539            .args(["-NoProfile", "-Command", script])
4540            .output()
4541        {
4542            let text = String::from_utf8_lossy(&out.stdout);
4543            let text = text.trim();
4544            if text.starts_with("OK") {
4545                let latency = text.split('|').nth(1).unwrap_or("?");
4546                let latency_ms: u64 = latency.parse().unwrap_or(0);
4547                let msg = format!("Internet connectivity: reachable ({}ms RTT)", latency_ms);
4548                if latency_ms > 300 {
4549                    watch.push(format!("{msg} — high latency, may indicate network issue."));
4550                } else {
4551                    good.push(msg);
4552                }
4553            } else {
4554                needs_fix.push(
4555                    "Internet connectivity: unreachable — could not ping 1.1.1.1. \
4556                     Check adapter, gateway, or DNS."
4557                        .to_string(),
4558                );
4559            }
4560            return;
4561        }
4562        watch.push("Network: could not run connectivity check.".to_string());
4563    }
4564
4565    #[cfg(not(target_os = "windows"))]
4566    {
4567        let _ = watch;
4568        let ok = Command::new("ping")
4569            .args(["-c", "1", "-W", "2", "1.1.1.1"])
4570            .stdout(std::process::Stdio::null())
4571            .stderr(std::process::Stdio::null())
4572            .status()
4573            .map(|s| s.success())
4574            .unwrap_or(false);
4575        if ok {
4576            good.push("Internet connectivity: reachable.".to_string());
4577        } else {
4578            needs_fix.push("Internet connectivity: unreachable — cannot ping 1.1.1.1.".to_string());
4579        }
4580    }
4581}
4582
4583fn health_check_pending_reboot(watch: &mut Vec<String>, good: &mut Vec<String>) {
4584    #[cfg(target_os = "windows")]
4585    {
4586        let script = r#"try {
4587    $pending = $false
4588    $reasons = @()
4589    if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending' -ErrorAction SilentlyContinue) {
4590        $pending = $true; $reasons += 'CBS/component update'
4591    }
4592    if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -ErrorAction SilentlyContinue) {
4593        $pending = $true; $reasons += 'Windows Update'
4594    }
4595    $pfr = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue)
4596    if ($pfr -and $pfr.PendingFileRenameOperations) {
4597        $pending = $true; $reasons += 'file rename ops'
4598    }
4599    if ($pending) { "PENDING|$($reasons -join ',')" } else { "OK" }
4600} catch { "OK" }"#;
4601        if let Ok(out) = Command::new("powershell")
4602            .args(["-NoProfile", "-Command", script])
4603            .output()
4604        {
4605            let text = String::from_utf8_lossy(&out.stdout);
4606            let text = text.trim();
4607            if text.starts_with("PENDING") {
4608                let reasons = text.split('|').nth(1).unwrap_or("unknown reason");
4609                watch.push(format!(
4610                    "Pending reboot required ({reasons}) — restart when convenient to apply changes."
4611                ));
4612            } else {
4613                good.push("No pending reboot.".to_string());
4614            }
4615        }
4616    }
4617
4618    #[cfg(not(target_os = "windows"))]
4619    {
4620        // Linux: check if a kernel update is pending (requires reboot to take effect)
4621        if std::path::Path::new("/var/run/reboot-required").exists() {
4622            watch.push(
4623                "Pending reboot required — a kernel or package update needs a restart.".to_string(),
4624            );
4625        } else {
4626            good.push("No pending reboot.".to_string());
4627        }
4628    }
4629}
4630
4631fn health_check_services(
4632    needs_fix: &mut Vec<String>,
4633    watch: &mut Vec<String>,
4634    good: &mut Vec<String>,
4635) {
4636    #[cfg(not(target_os = "windows"))]
4637    let _ = (&needs_fix, &good);
4638    #[cfg(target_os = "windows")]
4639    let _ = &watch;
4640
4641    #[cfg(target_os = "windows")]
4642    {
4643        // Only checks services whose being stopped indicates a real system problem.
4644        let script = r#"try {
4645    $names = @('EventLog','WinDefend','Dnscache')
4646    $stopped = @()
4647    foreach ($n in $names) {
4648        $s = Get-Service $n -ErrorAction SilentlyContinue
4649        if ($s -and $s.Status -ne 'Running') { $stopped += $n }
4650    }
4651    if ($stopped.Count -gt 0) { "STOPPED|$($stopped -join ',')" } else { "OK" }
4652} catch { "OK" }"#;
4653        if let Ok(out) = Command::new("powershell")
4654            .args(["-NoProfile", "-Command", script])
4655            .output()
4656        {
4657            let text = String::from_utf8_lossy(&out.stdout);
4658            let text = text.trim();
4659            if text.starts_with("STOPPED") {
4660                let names = text.split('|').nth(1).unwrap_or("unknown");
4661                needs_fix.push(format!(
4662                    "Critical service(s) not running: {names} — these should always be active."
4663                ));
4664            } else {
4665                good.push("Core services (Event Log, Defender, DNS) all running.".to_string());
4666            }
4667        }
4668    }
4669
4670    #[cfg(not(target_os = "windows"))]
4671    {
4672        // Linux: check systemd failed units
4673        if let Ok(out) = Command::new("systemctl")
4674            .args(["--failed", "--no-legend", "--plain"])
4675            .output()
4676        {
4677            let text = String::from_utf8_lossy(&out.stdout);
4678            let failed: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4679            if !failed.is_empty() {
4680                watch.push(format!(
4681                    "{} failed systemd unit(s): {}",
4682                    failed.len(),
4683                    failed.join(", ")
4684                ));
4685            } else {
4686                good.push("No failed systemd units.".to_string());
4687            }
4688        }
4689    }
4690}
4691
4692fn health_check_thermal(watch: &mut Vec<String>, good: &mut Vec<String>) {
4693    #[cfg(target_os = "windows")]
4694    {
4695        // WMI thermal zones — best-effort, silently skip if unavailable or requires elevation.
4696        let script = r#"try {
4697    $zones = Get-WmiObject -Namespace "root/wmi" -Class MSAcpi_ThermalZoneTemperature -ErrorAction Stop
4698    $temps = $zones | ForEach-Object { [math]::Round(($_.CurrentTemperature - 2732) / 10, 1) }
4699    $max = ($temps | Measure-Object -Maximum).Maximum
4700    "$max"
4701} catch { "NA" }"#;
4702        if let Ok(out) = Command::new("powershell")
4703            .args(["-NoProfile", "-Command", script])
4704            .output()
4705        {
4706            let text = String::from_utf8_lossy(&out.stdout);
4707            let text = text.trim();
4708            if text != "NA" && !text.is_empty() {
4709                if let Ok(temp) = text.parse::<f64>() {
4710                    let msg = format!("CPU thermal: {temp:.0}°C peak zone temperature");
4711                    if temp >= 90.0 {
4712                        watch.push(format!("{msg} — very high, check cooling and airflow."));
4713                    } else if temp >= 75.0 {
4714                        watch.push(format!(
4715                            "{msg} — elevated under load, monitor for throttling."
4716                        ));
4717                    } else {
4718                        good.push(format!("{msg} — normal."));
4719                    }
4720                }
4721            }
4722            // If NA or unparseable, skip silently — thermal WMI often needs admin.
4723        }
4724    }
4725
4726    #[cfg(not(target_os = "windows"))]
4727    {
4728        // Linux: read first available hwmon temp input
4729        let paths = [
4730            "/sys/class/thermal/thermal_zone0/temp",
4731            "/sys/class/hwmon/hwmon0/temp1_input",
4732        ];
4733        for path in &paths {
4734            if let Ok(content) = std::fs::read_to_string(path) {
4735                if let Ok(raw) = content.trim().parse::<u64>() {
4736                    let temp_c = raw / 1000;
4737                    let msg = format!("CPU thermal: {temp_c}°C");
4738                    if temp_c >= 90 {
4739                        watch.push(format!("{msg} — very high, check cooling."));
4740                    } else if temp_c >= 75 {
4741                        watch.push(format!("{msg} — elevated under load."));
4742                    } else {
4743                        good.push(format!("{msg} — normal."));
4744                    }
4745                    return;
4746                }
4747            }
4748        }
4749    }
4750}
4751
4752// ── log_check ─────────────────────────────────────────────────────────────────
4753
4754fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4755    let mut out = String::from("Host inspection: log_check\n\n");
4756
4757    #[cfg(target_os = "windows")]
4758    {
4759        // Pull recent critical/error events from Windows Application and System logs.
4760        let hours = lookback_hours.unwrap_or(24);
4761        let _ = write!(
4762            out,
4763            "Checking System/Application logs from the last {} hours...\n\n",
4764            hours
4765        );
4766
4767        let n = max_entries.clamp(1, 50);
4768        let script = format!(
4769            r#"try {{
4770    $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4771    if (-not $events) {{ "NO_EVENTS"; exit }}
4772    $events | Select-Object -First {n} | ForEach-Object {{
4773        $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4774        $line
4775    }}
4776}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4777            hours = hours,
4778            n = n
4779        );
4780        let output = Command::new("powershell")
4781            .args(["-NoProfile", "-Command", &script])
4782            .output()
4783            .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4784
4785        let raw = String::from_utf8_lossy(&output.stdout);
4786        let text = raw.trim();
4787
4788        if text.is_empty() || text == "NO_EVENTS" {
4789            out.push_str("No critical or error events found in Application/System logs.\n");
4790            return Ok(out.trim_end().to_string());
4791        }
4792        if text.starts_with("ERROR:") {
4793            let _ = writeln!(out, "Warning: event log query returned: {text}");
4794            return Ok(out.trim_end().to_string());
4795        }
4796
4797        let mut count = 0usize;
4798        for line in text.lines() {
4799            let mut it = line.splitn(4, '|');
4800            if let (Some(time), Some(level), Some(source), Some(msg)) =
4801                (it.next(), it.next(), it.next(), it.next())
4802            {
4803                let _ = writeln!(out, "[{time}] [{level}] {source}: {msg}");
4804                count += 1;
4805            }
4806        }
4807        let _ = write!(
4808            out,
4809            "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4810        );
4811    }
4812
4813    #[cfg(not(target_os = "windows"))]
4814    {
4815        let _ = lookback_hours;
4816        // Use journalctl on Linux/macOS if available.
4817        let n = max_entries.clamp(1, 50).to_string();
4818        let output = Command::new("journalctl")
4819            .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4820            .output();
4821
4822        match output {
4823            Ok(o) if o.status.success() => {
4824                let text = String::from_utf8_lossy(&o.stdout);
4825                let trimmed = text.trim();
4826                if trimmed.is_empty() || trimmed.contains("No entries") {
4827                    out.push_str("No critical or error entries found in the system journal.\n");
4828                } else {
4829                    out.push_str(trimmed);
4830                    out.push('\n');
4831                    out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4832                }
4833            }
4834            _ => {
4835                // Fallback: check /var/log/syslog or /var/log/messages
4836                let log_paths = ["/var/log/syslog", "/var/log/messages"];
4837                let mut found = false;
4838                for log_path in &log_paths {
4839                    if let Ok(content) = std::fs::read_to_string(log_path) {
4840                        let lines: Vec<&str> = content.lines().collect();
4841                        let mut tail: Vec<&str> = lines
4842                            .iter()
4843                            .rev()
4844                            .filter(|l| {
4845                                let l_lower = l.to_ascii_lowercase();
4846                                l_lower.contains("error") || l_lower.contains("crit")
4847                            })
4848                            .take(max_entries)
4849                            .copied()
4850                            .collect::<Vec<_>>();
4851                        tail.reverse();
4852                        if !tail.is_empty() {
4853                            let _ = write!(out, "Source: {log_path}\n");
4854                            for l in &tail {
4855                                out.push_str(l);
4856                                out.push('\n');
4857                            }
4858                            found = true;
4859                            break;
4860                        }
4861                    }
4862                }
4863                if !found {
4864                    out.push_str(
4865                        "journalctl not found and no readable syslog detected on this system.\n",
4866                    );
4867                }
4868            }
4869        }
4870    }
4871
4872    Ok(out.trim_end().to_string())
4873}
4874
4875// ── startup_items ─────────────────────────────────────────────────────────────
4876
4877fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4878    let mut out = String::from("Host inspection: startup_items\n\n");
4879
4880    #[cfg(target_os = "windows")]
4881    {
4882        // Query both HKLM and HKCU Run keys.
4883        let script = r#"
4884$hives = @(
4885    @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4886    @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4887    @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4888)
4889foreach ($h in $hives) {
4890    try {
4891        $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4892        $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4893            "$($h.Hive)|$($_.Name)|$($_.Value)"
4894        }
4895    } catch {}
4896}
4897"#;
4898        let output = Command::new("powershell")
4899            .args(["-NoProfile", "-Command", script])
4900            .output()
4901            .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4902
4903        let raw = String::from_utf8_lossy(&output.stdout);
4904        let text = raw.trim();
4905
4906        let entries: Vec<(String, String, String)> = text
4907            .lines()
4908            .filter_map(|l| {
4909                let mut it = l.splitn(3, '|');
4910                match (it.next(), it.next(), it.next()) {
4911                    (Some(a), Some(b), Some(c)) => {
4912                        Some((a.to_string(), b.to_string(), c.to_string()))
4913                    }
4914                    _ => None,
4915                }
4916            })
4917            .take(max_entries)
4918            .collect();
4919
4920        if entries.is_empty() {
4921            out.push_str("No startup entries found in the Windows Run registry keys.\n");
4922        } else {
4923            out.push_str("Registry run keys (programs that start with Windows):\n\n");
4924            let mut last_hive = String::new();
4925            for (hive, name, value) in &entries {
4926                if *hive != last_hive {
4927                    let _ = writeln!(out, "[{}]", hive);
4928                    last_hive = hive.clone();
4929                }
4930                // Truncate very long values (paths with many args)
4931                let display = if value.len() > 100 {
4932                    format!("{}…", safe_head(value, 100))
4933                } else {
4934                    value.clone()
4935                };
4936                let _ = writeln!(out, "  {name}: {display}");
4937            }
4938            let _ = write!(out, "\nTotal startup entries: {}\n", entries.len());
4939        }
4940
4941        // 3. Unified Startup Command check (Task Manager style)
4942        let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { "  $($_.Name): $($_.Command) ($($_.Location))" }"#;
4943        if let Ok(unified_out) = Command::new("powershell")
4944            .args(["-NoProfile", "-Command", unified_script])
4945            .output()
4946        {
4947            let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4948            let trimmed = unified_text.trim();
4949            if !trimmed.is_empty() {
4950                out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4951                out.push_str(trimmed);
4952                out.push('\n');
4953            }
4954        }
4955    }
4956
4957    #[cfg(not(target_os = "windows"))]
4958    {
4959        // On Linux: systemd enabled services + cron @reboot entries.
4960        let output = Command::new("systemctl")
4961            .args([
4962                "list-unit-files",
4963                "--type=service",
4964                "--state=enabled",
4965                "--no-legend",
4966                "--no-pager",
4967                "--plain",
4968            ])
4969            .output();
4970
4971        match output {
4972            Ok(o) if o.status.success() => {
4973                let text = String::from_utf8_lossy(&o.stdout);
4974                let services: Vec<&str> = text
4975                    .lines()
4976                    .filter(|l| !l.trim().is_empty())
4977                    .take(max_entries)
4978                    .collect();
4979                if services.is_empty() {
4980                    out.push_str("No enabled systemd services found.\n");
4981                } else {
4982                    out.push_str("Enabled systemd services (run at boot):\n\n");
4983                    for s in &services {
4984                        let _ = write!(out, "  {s}\n");
4985                    }
4986                    let _ = write!(out, "\nShowing {} of enabled services.\n", services.len());
4987                }
4988            }
4989            _ => {
4990                out.push_str(
4991                    "systemctl not found on this system. Cannot enumerate startup services.\n",
4992                );
4993            }
4994        }
4995
4996        // Check @reboot cron entries.
4997        if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4998            let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4999            let reboot_entries: Vec<&str> = cron_text
5000                .lines()
5001                .filter(|l| l.trim_start().starts_with("@reboot"))
5002                .collect();
5003            if !reboot_entries.is_empty() {
5004                out.push_str("\nCron @reboot entries:\n");
5005                for e in reboot_entries {
5006                    let _ = write!(out, "  {e}\n");
5007                }
5008            }
5009        }
5010    }
5011
5012    Ok(out.trim_end().to_string())
5013}
5014
5015fn inspect_os_config() -> Result<String, String> {
5016    let mut out = String::from("Host inspection: OS Configuration\n\n");
5017
5018    #[cfg(target_os = "windows")]
5019    {
5020        // Power Plan
5021        if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
5022            let power_str = String::from_utf8_lossy(&power_out.stdout);
5023            out.push_str("=== Power Plan ===\n");
5024            out.push_str(power_str.trim());
5025            out.push_str("\n\n");
5026        }
5027
5028        // Firewall Status
5029        let fw_script =
5030            "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
5031        if let Ok(fw_out) = Command::new("powershell")
5032            .args(["-NoProfile", "-Command", fw_script])
5033            .output()
5034        {
5035            let fw_str = String::from_utf8_lossy(&fw_out.stdout);
5036            out.push_str("=== Firewall Profiles ===\n");
5037            out.push_str(fw_str.trim());
5038            out.push_str("\n\n");
5039        }
5040
5041        // System Uptime
5042        let uptime_script =
5043            "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
5044        if let Ok(uptime_out) = Command::new("powershell")
5045            .args(["-NoProfile", "-Command", uptime_script])
5046            .output()
5047        {
5048            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
5049            out.push_str("=== System Uptime (Last Boot) ===\n");
5050            out.push_str(uptime_str.trim());
5051            out.push_str("\n\n");
5052        }
5053    }
5054
5055    #[cfg(not(target_os = "windows"))]
5056    {
5057        // Uptime
5058        if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
5059            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
5060            out.push_str("=== System Uptime ===\n");
5061            out.push_str(uptime_str.trim());
5062            out.push_str("\n\n");
5063        }
5064
5065        // Firewall (ufw status if available)
5066        if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
5067            let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
5068            if !ufw_str.trim().is_empty() {
5069                out.push_str("=== Firewall (UFW) ===\n");
5070                out.push_str(ufw_str.trim());
5071                out.push_str("\n\n");
5072            }
5073        }
5074    }
5075    Ok(out.trim_end().to_string())
5076}
5077
5078pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
5079    let action = args
5080        .get("action")
5081        .and_then(|v| v.as_str())
5082        .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
5083
5084    let target = args
5085        .get("target")
5086        .and_then(|v| v.as_str())
5087        .unwrap_or("")
5088        .trim();
5089
5090    if target.is_empty() && action != "clear_temp" {
5091        return Err("Missing required argument: 'target' for this action".to_string());
5092    }
5093
5094    match action {
5095        "install_package" => {
5096            #[cfg(target_os = "windows")]
5097            {
5098                let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
5099                match Command::new("powershell")
5100                    .args(["-NoProfile", "-Command", &cmd])
5101                    .output()
5102                {
5103                    Ok(out) => Ok(format!(
5104                        "Executed remediation (winget install):\n{}",
5105                        String::from_utf8_lossy(&out.stdout)
5106                    )),
5107                    Err(e) => Err(format!("Failed to run winget: {}", e)),
5108                }
5109            }
5110            #[cfg(not(target_os = "windows"))]
5111            {
5112                Err(
5113                    "install_package via wrapper is only supported on Windows currently (winget)"
5114                        .to_string(),
5115                )
5116            }
5117        }
5118        "restart_service" => {
5119            #[cfg(target_os = "windows")]
5120            {
5121                let cmd = format!("Restart-Service -Name {} -Force", target);
5122                match Command::new("powershell")
5123                    .args(["-NoProfile", "-Command", &cmd])
5124                    .output()
5125                {
5126                    Ok(out) => {
5127                        let err_str = String::from_utf8_lossy(&out.stderr);
5128                        if !err_str.is_empty() {
5129                            return Err(format!("Error restarting service:\n{}", err_str));
5130                        }
5131                        Ok(format!("Successfully restarted service: {}", target))
5132                    }
5133                    Err(e) => Err(format!("Failed to restart service: {}", e)),
5134                }
5135            }
5136            #[cfg(not(target_os = "windows"))]
5137            {
5138                Err(
5139                    "restart_service via wrapper is only supported on Windows currently"
5140                        .to_string(),
5141                )
5142            }
5143        }
5144        "clear_temp" => {
5145            #[cfg(target_os = "windows")]
5146            {
5147                let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
5148                match Command::new("powershell")
5149                    .args(["-NoProfile", "-Command", cmd])
5150                    .output()
5151                {
5152                    Ok(_) => Ok("Successfully cleared temporary files".to_string()),
5153                    Err(e) => Err(format!("Failed to clear temp: {}", e)),
5154                }
5155            }
5156            #[cfg(not(target_os = "windows"))]
5157            {
5158                Err("clear_temp via wrapper is only supported on Windows currently".to_string())
5159            }
5160        }
5161        other => Err(format!("Unknown remediation action: {}", other)),
5162    }
5163}
5164
5165// ── storage ───────────────────────────────────────────────────────────────────
5166
5167fn inspect_storage(max_entries: usize) -> Result<String, String> {
5168    let mut out = String::from("Host inspection: storage\n\n");
5169    let _ = max_entries; // used by non-Windows branch
5170
5171    // ── Drive overview ────────────────────────────────────────────────────────
5172    out.push_str("Drives:\n");
5173
5174    #[cfg(target_os = "windows")]
5175    {
5176        let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
5177    $free = $_.Free
5178    $used = $_.Used
5179    if ($free -eq $null) { $free = 0 }
5180    if ($used -eq $null) { $used = 0 }
5181    $total = $free + $used
5182    "$($_.Name)|$free|$used|$total"
5183}"#;
5184        match Command::new("powershell")
5185            .args(["-NoProfile", "-Command", script])
5186            .output()
5187        {
5188            Ok(o) => {
5189                let text = String::from_utf8_lossy(&o.stdout);
5190                let mut drive_count = 0usize;
5191                for line in text.lines() {
5192                    let mut it = line.trim().splitn(5, '|');
5193                    if let (Some(name), Some(p1), _, Some(p3)) =
5194                        (it.next(), it.next(), it.next(), it.next())
5195                    {
5196                        let free: u64 = p1.parse().unwrap_or(0);
5197                        let total: u64 = p3.parse().unwrap_or(0);
5198                        if total == 0 {
5199                            continue;
5200                        }
5201                        let free_gb = free / 1_073_741_824;
5202                        let total_gb = total / 1_073_741_824;
5203                        let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
5204                        let bar_len = 20usize;
5205                        let filled = (used_pct as usize * bar_len / 100).min(bar_len);
5206                        let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
5207                        let warn = if free_gb < 5 {
5208                            " [!] CRITICALLY LOW"
5209                        } else if free_gb < 15 {
5210                            " [-] LOW"
5211                        } else {
5212                            ""
5213                        };
5214                        let _ = writeln!(out,
5215                            "  {name}:  [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}"
5216                        );
5217                        drive_count += 1;
5218                    }
5219                }
5220                if drive_count == 0 {
5221                    out.push_str("  (could not enumerate drives)\n");
5222                }
5223            }
5224            Err(e) => {
5225                let _ = writeln!(out, "  (drive scan failed: {e})");
5226            }
5227        }
5228
5229        // ── Real-time Performance (Latency) ──────────────────────────────────
5230        let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
5231        match Command::new("powershell")
5232            .args(["-NoProfile", "-Command", latency_script])
5233            .output()
5234        {
5235            Ok(o) => {
5236                out.push_str("\nReal-time Disk Intensity:\n");
5237                let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5238                if !text.is_empty() {
5239                    let _ = writeln!(out, "  Average Disk Queue Length: {text}");
5240                    if let Ok(q) = text.parse::<f64>() {
5241                        if q > 2.0 {
5242                            out.push_str(
5243                                "  [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
5244                            );
5245                        } else {
5246                            out.push_str("  [~] Disk latency is within healthy bounds.\n");
5247                        }
5248                    }
5249                } else {
5250                    out.push_str("  Average Disk Queue Length: unavailable\n");
5251                }
5252            }
5253            Err(_) => {
5254                out.push_str("\nReal-time Disk Intensity:\n");
5255                out.push_str("  Average Disk Queue Length: unavailable\n");
5256            }
5257        }
5258    }
5259
5260    #[cfg(not(target_os = "windows"))]
5261    {
5262        match Command::new("df")
5263            .args(["-h", "--output=target,size,avail,pcent"])
5264            .output()
5265        {
5266            Ok(o) => {
5267                let text = String::from_utf8_lossy(&o.stdout);
5268                let mut count = 0usize;
5269                for line in text.lines().skip(1) {
5270                    let mut it = line.split_whitespace();
5271                    if let (Some(fs), Some(size), Some(avail), Some(used)) =
5272                        (it.next(), it.next(), it.next(), it.next())
5273                    {
5274                        if !fs.starts_with("tmpfs") {
5275                            let _ = write!(
5276                                out,
5277                                "  {}  size: {}  avail: {}  used: {}\n",
5278                                fs, size, avail, used
5279                            );
5280                            count += 1;
5281                            if count >= max_entries {
5282                                break;
5283                            }
5284                        }
5285                    }
5286                }
5287            }
5288            Err(e) => {
5289                let _ = write!(out, "  (df failed: {e})\n");
5290            }
5291        }
5292    }
5293
5294    // ── Large developer cache directories ─────────────────────────────────────
5295    out.push_str("\nLarge developer cache directories (if present):\n");
5296
5297    #[cfg(target_os = "windows")]
5298    {
5299        let home = std::env::var("USERPROFILE").unwrap_or_default();
5300        let check_dirs: &[(&str, &str)] = &[
5301            ("Temp", r"AppData\Local\Temp"),
5302            ("npm cache", r"AppData\Roaming\npm-cache"),
5303            ("Cargo registry", r".cargo\registry"),
5304            ("Cargo git", r".cargo\git"),
5305            ("pip cache", r"AppData\Local\pip\cache"),
5306            ("Yarn cache", r"AppData\Local\Yarn\Cache"),
5307            (".rustup toolchains", r".rustup\toolchains"),
5308            ("node_modules (home)", r"node_modules"),
5309        ];
5310
5311        let mut found_any = false;
5312        for (label, rel) in check_dirs {
5313            let full = format!(r"{}\{}", home, rel);
5314            let path = std::path::Path::new(&full);
5315            if path.exists() {
5316                // Quick size estimate via PowerShell (non-blocking cap at 5s)
5317                let size_script = format!(
5318                    r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
5319                    full.replace('\'', "''")
5320                );
5321                let size_mb = Command::new("powershell")
5322                    .args(["-NoProfile", "-Command", &size_script])
5323                    .output()
5324                    .ok()
5325                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
5326                    .unwrap_or_else(|| "?".to_string());
5327                let _ = writeln!(out, "  {label}: {size_mb} MB  ({full})");
5328                found_any = true;
5329            }
5330        }
5331        if !found_any {
5332            out.push_str("  (none of the common cache directories found)\n");
5333        }
5334
5335        out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
5336    }
5337
5338    #[cfg(not(target_os = "windows"))]
5339    {
5340        let home = std::env::var("HOME").unwrap_or_default();
5341        let check_dirs: &[(&str, &str)] = &[
5342            ("npm cache", ".npm"),
5343            ("Cargo registry", ".cargo/registry"),
5344            ("pip cache", ".cache/pip"),
5345            (".rustup toolchains", ".rustup/toolchains"),
5346            ("Yarn cache", ".cache/yarn"),
5347        ];
5348        let mut found_any = false;
5349        for (label, rel) in check_dirs {
5350            let full = format!("{}/{}", home, rel);
5351            if std::path::Path::new(&full).exists() {
5352                let size = Command::new("du")
5353                    .args(["-sh", &full])
5354                    .output()
5355                    .ok()
5356                    .map(|o| {
5357                        let s = String::from_utf8_lossy(&o.stdout);
5358                        s.split_whitespace().next().unwrap_or("?").to_string()
5359                    })
5360                    .unwrap_or_else(|| "?".to_string());
5361                let _ = write!(out, "  {label}: {size}  ({full})\n");
5362                found_any = true;
5363            }
5364        }
5365        if !found_any {
5366            out.push_str("  (none of the common cache directories found)\n");
5367        }
5368    }
5369
5370    Ok(out.trim_end().to_string())
5371}
5372
5373// ── storage_deep ──────────────────────────────────────────────────────────────
5374
5375fn inspect_storage_deep() -> Result<String, String> {
5376    let mut out = String::from("Host inspection: storage_deep\n\n");
5377    out.push_str(
5378        "Deep storage analysis — scanning drives, top directories, and dev artifact caches.\n\n",
5379    );
5380
5381    #[cfg(target_os = "windows")]
5382    {
5383        let script = r#"
5384# Fast FSO folder sizer
5385function sz($p) {
5386    try { [long](New-Object -ComObject Scripting.FileSystemObject).GetFolder($p).Size }
5387    catch { -1L }
5388}
5389
5390# ── Section 1: Drive overview ──
5391$drives = Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue |
5392    Where-Object { $_.Used -ne $null -and ($_.Used + $_.Free) -gt 0 }
5393foreach ($d in $drives) {
5394    $total = $d.Used + $d.Free
5395    $pct = [math]::Round($d.Used / $total * 100, 1)
5396    "DRIVE|$($d.Root)|$($d.Used)|$($d.Free)|$total|$pct"
5397}
5398"---END_DRIVES---"
5399
5400# ── Section 2: Known large user paths ──
5401$H = $env:USERPROFILE
5402$knownPaths = @(
5403    @{ l="Downloads";                  p="$H\Downloads";                                    hint="Review — delete old files or move to external drive" },
5404    @{ l="Videos";                     p="$H\Videos";                                       hint="Review — move to external drive or NAS" },
5405    @{ l="Documents";                  p="$H\Documents";                                    hint="Review for large files" },
5406    @{ l="Pictures";                   p="$H\Pictures";                                     hint="Review for duplicates" },
5407    @{ l="Desktop";                    p="$H\Desktop";                                      hint="Move large files off desktop" },
5408    @{ l="User Temp";                  p="$H\AppData\Local\Temp";                           hint="Safe to delete — clear with: cleanmgr /sageset:1" },
5409    @{ l="Windows Temp";               p="C:\Windows\Temp";                                 hint="Safe to delete (close apps first)" },
5410    @{ l="WU Download Cache";          p="C:\Windows\SoftwareDistribution\Download";        hint="Safe after reboot: net stop wuauserv, delete contents, net start wuauserv" },
5411    @{ l="IE/Edge Cache";              p="$H\AppData\Local\Microsoft\Windows\INetCache";    hint="Clear in browser settings" },
5412    @{ l="Chrome Cache";               p="$H\AppData\Local\Google\Chrome\User Data\Default\Cache"; hint="Clear in Chrome: chrome://settings/clearBrowserData" },
5413    @{ l="Edge Cache";                 p="$H\AppData\Local\Microsoft\Edge\User Data\Default\Cache"; hint="Clear in Edge: edge://settings/clearBrowserData" },
5414    @{ l="Teams Cache (classic)";      p="$H\AppData\Roaming\Microsoft\Teams";              hint="Safe to clear — close Teams first, delete contents of Cache/ and blob_storage/" },
5415    @{ l="Teams Cache (new)";          p="$H\AppData\Local\Packages\MSTeams_8wekyb3d8bbwe\LocalCache"; hint="Close new Teams, then clear LocalCache" },
5416    @{ l="Cargo Registry";             p="$H\.cargo\registry";                              hint="cargo cache --autoclean  OR  delete registry\src\* to reclaim" },
5417    @{ l="Cargo Git";                  p="$H\.cargo\git";                                   hint="cargo cache --autoclean" },
5418    @{ l="Rustup Toolchains";          p="$H\.rustup\toolchains";                           hint="rustup toolchain remove <old-version> to prune" },
5419    @{ l="npm Cache";                  p="$H\AppData\Roaming\npm-cache";                    hint="npm cache clean --force" },
5420    @{ l="Yarn Cache";                 p="$H\AppData\Local\Yarn\Cache";                     hint="yarn cache clean" },
5421    @{ l="pip Cache";                  p="$H\AppData\Local\pip\cache";                      hint="pip cache purge" },
5422    @{ l="Gradle Cache";               p="$H\.gradle\caches";                               hint="gradle cleanBuildCache  OR  delete .gradle\caches" },
5423    @{ l="Maven Repository";           p="$H\.m2\repository";                               hint="mvn dependency:purge-local-repository" },
5424    @{ l="NuGet Packages";             p="$H\.nuget\packages";                              hint="dotnet nuget locals all --clear" },
5425    @{ l="Docker Desktop Disk Image";  p="$H\AppData\Local\Docker\wsl\data";                hint="docker system prune -a to reclaim unused images/containers" },
5426    @{ l="WSL ext4 VHD";              p="$H\AppData\Local\Packages\CanonicalGroupLimited.Ubuntu_79rhkp1fndgsc\LocalState"; hint="Compact with: wsl --shutdown; Optimize-VHD" }
5427)
5428
5429foreach ($item in $knownPaths) {
5430    if (Test-Path $item.p -ErrorAction SilentlyContinue) {
5431        $bytes = sz $item.p
5432        if ($bytes -gt 1MB) {
5433            "PATH|$($item.l)|$($item.p)|$bytes|$($item.hint)"
5434        }
5435    }
5436}
5437"---END_PATHS---"
5438
5439# ── Section 3: Dev artifact discovery ──
5440$devRoots = @(
5441    "$env:USERPROFILE\source", "$env:USERPROFILE\repos", "$env:USERPROFILE\projects",
5442    "$env:USERPROFILE\dev",    "$env:USERPROFILE\code",   "$env:USERPROFILE\Desktop",
5443    "$env:USERPROFILE\Documents", "C:\source", "C:\repos", "C:\projects", "C:\dev", "C:\code"
5444) | Where-Object { Test-Path $_ -ErrorAction SilentlyContinue }
5445
5446$artDefs = @(
5447    @{ filter="node_modules"; label="node_modules (JS/TS)"; fix="Delete folder then run: npm install" },
5448    @{ filter="target";       label="target/ (Rust build)"; fix="cargo clean  (rebuilds on next cargo build)" },
5449    @{ filter=".venv";        label=".venv (Python venv)";  fix="Delete and recreate: python -m venv .venv" },
5450    @{ filter="venv";         label="venv (Python venv)";   fix="Delete and recreate: python -m venv venv" },
5451    @{ filter="dist";         label="dist/ (build output)"; fix="Delete — regenerated by your build tool" },
5452    @{ filter=".next";        label=".next (Next.js cache)"; fix="Delete — regenerated by next build" },
5453    @{ filter=".nuxt";        label=".nuxt (Nuxt cache)";   fix="Delete — regenerated by nuxt build" }
5454)
5455
5456foreach ($root in $devRoots) {
5457    foreach ($art in $artDefs) {
5458        $dirs = Get-ChildItem -Path $root -Recurse -Directory -Depth 5 `
5459                              -Filter $art.filter -ErrorAction SilentlyContinue |
5460                Select-Object -First 40
5461        foreach ($dir in $dirs) {
5462            $bytes = sz $dir.FullName
5463            if ($bytes -gt 5MB) {
5464                "ARTIFACT|$($art.label)|$($dir.FullName)|$bytes|$($art.fix)"
5465            }
5466        }
5467    }
5468}
5469"---END_ARTIFACTS---"
5470"#;
5471
5472        match Command::new("powershell")
5473            .args(["-NoProfile", "-Command", script])
5474            .output()
5475        {
5476            Ok(o) => {
5477                let raw = String::from_utf8_lossy(&o.stdout);
5478                let sections: Vec<&str> = raw.split("---END_").collect();
5479
5480                // Parse drives
5481                let mut drive_lines: Vec<String> = Vec::new();
5482                if let Some(sec) = sections.first() {
5483                    for line in sec.lines() {
5484                        if !line.starts_with("DRIVE|") {
5485                            continue;
5486                        }
5487                        let mut it = line.splitn(7, '|');
5488                        it.next(); // "DRIVE"
5489                        if let (Some(root), Some(used_s), Some(free_s), Some(total_s), Some(_pct)) =
5490                            (it.next(), it.next(), it.next(), it.next(), it.next())
5491                        {
5492                            let used: u64 = used_s.trim().parse().unwrap_or(0);
5493                            let free: u64 = free_s.trim().parse().unwrap_or(0);
5494                            let total: u64 = total_s.trim().parse().unwrap_or(0);
5495                            if total == 0 {
5496                                continue;
5497                            }
5498                            let pct = (used as f64 / total as f64 * 100.0) as u64;
5499                            let bar_len = 28usize;
5500                            let filled = (pct as usize * bar_len / 100).min(bar_len);
5501                            let bar = "#".repeat(filled) + &".".repeat(bar_len - filled);
5502                            let warn = if free < 5_368_709_120 {
5503                                " [!] CRITICALLY LOW"
5504                            } else if free < 16_106_127_360 {
5505                                " [-] LOW"
5506                            } else {
5507                                ""
5508                            };
5509                            drive_lines.push(format!(
5510                                "  {root}  [{bar}] {pct}% used — {} free of {}{}",
5511                                human_bytes(free),
5512                                human_bytes(total),
5513                                warn
5514                            ));
5515                        }
5516                    }
5517                }
5518
5519                // Parse known paths
5520                struct PathEntry {
5521                    label: String,
5522                    bytes: u64,
5523                    path: String,
5524                    hint: String,
5525                }
5526                let mut path_entries: Vec<PathEntry> = Vec::new();
5527                if let Some(sec) = sections.get(1) {
5528                    for line in sec.lines() {
5529                        if !line.starts_with("PATH|") {
5530                            continue;
5531                        }
5532                        let mut it = line.splitn(6, '|');
5533                        it.next();
5534                        if let (Some(label), Some(path), Some(bytes_s), Some(hint)) =
5535                            (it.next(), it.next(), it.next(), it.next())
5536                        {
5537                            let bytes: u64 = bytes_s.trim().parse().unwrap_or(0);
5538                            if bytes == 0 {
5539                                continue;
5540                            }
5541                            path_entries.push(PathEntry {
5542                                label: label.to_string(),
5543                                bytes,
5544                                path: path.to_string(),
5545                                hint: hint.to_string(),
5546                            });
5547                        }
5548                    }
5549                    path_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes));
5550                }
5551
5552                // Parse artifacts
5553                struct ArtEntry {
5554                    label: String,
5555                    path: String,
5556                    bytes: u64,
5557                    fix: String,
5558                }
5559                let mut art_entries: Vec<ArtEntry> = Vec::new();
5560                if let Some(sec) = sections.get(2) {
5561                    for line in sec.lines() {
5562                        if !line.starts_with("ARTIFACT|") {
5563                            continue;
5564                        }
5565                        let mut it = line.splitn(6, '|');
5566                        it.next();
5567                        if let (Some(label), Some(path), Some(bytes_s), Some(fix)) =
5568                            (it.next(), it.next(), it.next(), it.next())
5569                        {
5570                            let bytes: u64 = bytes_s.trim().parse().unwrap_or(0);
5571                            art_entries.push(ArtEntry {
5572                                label: label.to_string(),
5573                                path: path.to_string(),
5574                                bytes,
5575                                fix: fix.to_string(),
5576                            });
5577                        }
5578                    }
5579                    art_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes));
5580                }
5581
5582                // ── Output: Drive overview ────────────────────────────────
5583                out.push_str("Drives:\n");
5584                if drive_lines.is_empty() {
5585                    out.push_str("  (could not enumerate drives)\n");
5586                } else {
5587                    for line in &drive_lines {
5588                        out.push_str(line);
5589                        out.push('\n');
5590                    }
5591                }
5592
5593                // ── Output: Top space consumers ───────────────────────────
5594                out.push_str("\nTop space consumers:\n");
5595                if path_entries.is_empty() {
5596                    out.push_str("  (no large directories found in known locations)\n");
5597                } else {
5598                    for e in path_entries.iter().take(20) {
5599                        let _ = writeln!(
5600                            out,
5601                            "  {:>8}  {}  ({})",
5602                            human_bytes(e.bytes),
5603                            e.label,
5604                            e.path
5605                        );
5606                    }
5607                }
5608
5609                // ── Output: Dev artifact caches ───────────────────────────
5610                if !art_entries.is_empty() {
5611                    let total_artifact_bytes: u64 = art_entries.iter().map(|a| a.bytes).sum();
5612                    let _ = writeln!(
5613                        out,
5614                        "\nDev artifact caches found ({} total across {} directories):",
5615                        human_bytes(total_artifact_bytes),
5616                        art_entries.len()
5617                    );
5618                    for e in art_entries.iter().take(30) {
5619                        let _ = writeln!(
5620                            out,
5621                            "  {:>8}  [{}]  {}\n            Fix: {}",
5622                            human_bytes(e.bytes),
5623                            e.label,
5624                            e.path,
5625                            e.fix
5626                        );
5627                    }
5628                } else {
5629                    out.push_str(
5630                        "\nDev artifact caches: none found in common project locations.\n",
5631                    );
5632                }
5633
5634                // ── Findings ──────────────────────────────────────────────
5635                out.push_str("\nFindings:\n");
5636                let mut findings: Vec<String> = Vec::new();
5637
5638                // Low disk findings from drive data
5639                for line in &drive_lines {
5640                    if line.contains("[!] CRITICALLY LOW") {
5641                        let drive_name = line.trim().chars().take(3).collect::<String>();
5642                        findings.push(format!(
5643                            "  [ACTION] Drive {} is critically low on space — immediate cleanup required.", drive_name
5644                        ));
5645                    } else if line.contains("[-] LOW") {
5646                        let drive_name = line.trim().chars().take(3).collect::<String>();
5647                        findings.push(format!(
5648                            "  [INVESTIGATE] Drive {} is low on space — review and clean up soon.",
5649                            drive_name
5650                        ));
5651                    }
5652                }
5653
5654                // Large temp/cache findings
5655                if let Some(e) = path_entries
5656                    .iter()
5657                    .find(|e| e.label.contains("Temp") || e.label.contains("Cache"))
5658                {
5659                    if e.bytes > 1_073_741_824 {
5660                        findings.push(format!(
5661                            "  [ACTION] {} is using {} — {}",
5662                            e.label,
5663                            human_bytes(e.bytes),
5664                            e.hint
5665                        ));
5666                    }
5667                }
5668
5669                // Teams cache
5670                if let Some(e) = path_entries
5671                    .iter()
5672                    .find(|e| e.label.contains("Teams Cache"))
5673                {
5674                    if e.bytes > 536_870_912 {
5675                        findings.push(format!(
5676                            "  [ACTION] {} using {} — {}",
5677                            e.label,
5678                            human_bytes(e.bytes),
5679                            e.hint
5680                        ));
5681                    }
5682                }
5683
5684                // Large artifact collections
5685                if !art_entries.is_empty() {
5686                    let total: u64 = art_entries.iter().map(|a| a.bytes).sum();
5687                    if total > 1_073_741_824 {
5688                        findings.push(format!(
5689                            "  [ACTION] Dev build artifacts total {} across {} directories — safe to delete and rebuild.",
5690                            human_bytes(total), art_entries.len()
5691                        ));
5692                    }
5693                }
5694
5695                // Large downloads
5696                if let Some(e) = path_entries.iter().find(|e| e.label == "Downloads") {
5697                    if e.bytes > 5_368_709_120 {
5698                        findings.push(format!(
5699                            "  [MONITOR] Downloads folder is {} — review for large files to archive or delete.",
5700                            human_bytes(e.bytes)
5701                        ));
5702                    }
5703                }
5704
5705                if findings.is_empty() {
5706                    out.push_str(
5707                        "  Storage usage looks healthy — no major space issues detected.\n",
5708                    );
5709                } else {
5710                    for f in &findings {
5711                        out.push_str(f);
5712                        out.push('\n');
5713                    }
5714                }
5715
5716                out.push_str("\nTip: ask the AI 'where did my disk space go?' or 'help me clean up my C drive' for a guided cleanup plan.\n");
5717            }
5718            Err(e) => {
5719                let _ = writeln!(out, "(storage deep scan failed: {e})");
5720            }
5721        }
5722    }
5723
5724    #[cfg(not(target_os = "windows"))]
5725    {
5726        let home = std::env::var("HOME").unwrap_or_default();
5727        // Drive overview
5728        out.push_str("Drives:\n");
5729        if let Ok(o) = Command::new("df")
5730            .args(["-h", "--output=target,size,avail,pcent"])
5731            .output()
5732        {
5733            for line in String::from_utf8_lossy(&o.stdout).lines().skip(1) {
5734                let mut it = line.split_whitespace();
5735                if let (Some(fs), Some(sz), Some(av), Some(pct)) =
5736                    (it.next(), it.next(), it.next(), it.next())
5737                {
5738                    if !fs.starts_with("tmpfs") {
5739                        let _ = writeln!(out, "  {fs}  size: {sz}  avail: {av}  used: {pct}");
5740                    }
5741                }
5742            }
5743        }
5744
5745        // Known large paths
5746        out.push_str("\nTop space consumers:\n");
5747        let check: &[(&str, &str, &str)] = &[
5748            ("Downloads", "Downloads", "review for large files"),
5749            ("npm Cache", ".npm", "npm cache clean --force"),
5750            (
5751                "Cargo Registry",
5752                ".cargo/registry",
5753                "cargo cache --autoclean",
5754            ),
5755            ("Cargo Git", ".cargo/git", "cargo cache --autoclean"),
5756            ("pip Cache", ".cache/pip", "pip cache purge"),
5757            (
5758                "Rustup",
5759                ".rustup/toolchains",
5760                "rustup toolchain remove <old>",
5761            ),
5762            ("Gradle", ".gradle/caches", "gradle cleanBuildCache"),
5763            (
5764                "Maven",
5765                ".m2/repository",
5766                "mvn dependency:purge-local-repository",
5767            ),
5768        ];
5769        for (label, rel, hint) in check {
5770            let full = format!("{home}/{rel}");
5771            if std::path::Path::new(&full).exists() {
5772                if let Ok(o) = Command::new("du").args(["-sh", &full]).output() {
5773                    let sz = String::from_utf8_lossy(&o.stdout);
5774                    let sz = sz.split_whitespace().next().unwrap_or("?");
5775                    let _ = writeln!(out, "  {sz:>8}  {label}  ({full})  — {hint}");
5776                }
5777            }
5778        }
5779
5780        // Dev artifact search
5781        out.push_str("\nDev artifact caches:\n");
5782        for pattern in &["node_modules", "target", ".venv", "venv", "dist", ".next"] {
5783            if let Ok(o) = Command::new("find")
5784                .args([&home, "-name", pattern, "-maxdepth", "7", "-type", "d"])
5785                .output()
5786            {
5787                for dir in String::from_utf8_lossy(&o.stdout).lines().take(20) {
5788                    if let Ok(o2) = Command::new("du").args(["-sh", dir]).output() {
5789                        let sz = String::from_utf8_lossy(&o2.stdout);
5790                        let sz = sz.split_whitespace().next().unwrap_or("?");
5791                        let _ = writeln!(out, "  {sz:>8}  [{pattern}]  {dir}");
5792                    }
5793                }
5794            }
5795        }
5796    }
5797
5798    Ok(out.trim_end().to_string())
5799}
5800
5801// ── hardware ──────────────────────────────────────────────────────────────────
5802
5803fn inspect_hardware() -> Result<String, String> {
5804    let mut out = String::from("Host inspection: hardware\n\n");
5805
5806    #[cfg(target_os = "windows")]
5807    {
5808        // CPU
5809        let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
5810    "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
5811} | Select-Object -First 1"#;
5812        if let Ok(o) = Command::new("powershell")
5813            .args(["-NoProfile", "-Command", cpu_script])
5814            .output()
5815        {
5816            let text = String::from_utf8_lossy(&o.stdout);
5817            let text = text.trim();
5818            let mut it = text.splitn(5, '|');
5819            if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
5820                (it.next(), it.next(), it.next(), it.next())
5821            {
5822                let _ = write!(
5823                    out,
5824                    "CPU: {}\n  {} physical cores, {} logical processors, {:.1} GHz\n\n",
5825                    p0,
5826                    p1,
5827                    p2,
5828                    p3.parse::<f32>().unwrap_or(0.0)
5829                );
5830            } else {
5831                let _ = write!(out, "CPU: {text}\n\n");
5832            }
5833        }
5834
5835        // RAM (total installed + speed)
5836        let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5837$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5838$speed = ($sticks | Select-Object -First 1).Speed
5839"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5840        if let Ok(o) = Command::new("powershell")
5841            .args(["-NoProfile", "-Command", ram_script])
5842            .output()
5843        {
5844            let text = String::from_utf8_lossy(&o.stdout);
5845            let _ = write!(out, "RAM: {}\n\n", text.trim().trim_matches('"'));
5846        }
5847
5848        // GPU(s)
5849        let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5850    "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5851}"#;
5852        if let Ok(o) = Command::new("powershell")
5853            .args(["-NoProfile", "-Command", gpu_script])
5854            .output()
5855        {
5856            let text = String::from_utf8_lossy(&o.stdout);
5857            let lines: Vec<&str> = text.lines().collect();
5858            if !lines.is_empty() {
5859                out.push_str("GPU(s):\n");
5860                for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5861                    let mut it = line.trim().splitn(4, '|');
5862                    if let (Some(p0), Some(p1), Some(p2)) = (it.next(), it.next(), it.next()) {
5863                        let res = if p2 == "x" || p2.starts_with('0') {
5864                            String::new()
5865                        } else {
5866                            format!(" — {}@display", p2)
5867                        };
5868                        let _ = write!(out, "  {}\n    Driver: {}{}\n", p0, p1, res);
5869                    } else {
5870                        let _ = writeln!(out, "  {}", line.trim());
5871                    }
5872                }
5873                out.push('\n');
5874            }
5875        }
5876
5877        // Motherboard + BIOS + Virtualization
5878        let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5879$bios = Get-CimInstance Win32_BIOS
5880$cs = Get-CimInstance Win32_ComputerSystem
5881$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5882$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5883"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5884        if let Ok(o) = Command::new("powershell")
5885            .args(["-NoProfile", "-Command", mb_script])
5886            .output()
5887        {
5888            let text = String::from_utf8_lossy(&o.stdout);
5889            let text = text.trim().trim_matches('"');
5890            let mut it = text.splitn(5, '|');
5891            if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
5892                (it.next(), it.next(), it.next(), it.next())
5893            {
5894                let _ = write!(
5895                    out,
5896                    "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5897                    p0.trim(),
5898                    p1.trim(),
5899                    p2.trim(),
5900                    p3.trim()
5901                );
5902            }
5903        }
5904
5905        // Display(s)
5906        let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5907    "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5908}"#;
5909        if let Ok(o) = Command::new("powershell")
5910            .args(["-NoProfile", "-Command", disp_script])
5911            .output()
5912        {
5913            let text = String::from_utf8_lossy(&o.stdout);
5914            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5915            if !lines.is_empty() {
5916                out.push_str("Display(s):\n");
5917                for line in &lines {
5918                    let mut it = line.trim().splitn(3, '|');
5919                    if let (Some(p0), Some(p1)) = (it.next(), it.next()) {
5920                        let _ = writeln!(out, "  {} — {}", p0.trim(), p1);
5921                    }
5922                }
5923            }
5924        }
5925    }
5926
5927    #[cfg(not(target_os = "windows"))]
5928    {
5929        // CPU via /proc/cpuinfo
5930        if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5931            let model = content
5932                .lines()
5933                .find(|l| l.starts_with("model name"))
5934                .and_then(|l| l.split(':').nth(1))
5935                .map(str::trim)
5936                .unwrap_or("unknown");
5937            let cores = content
5938                .lines()
5939                .filter(|l| l.starts_with("processor"))
5940                .count();
5941            let _ = write!(out, "CPU: {model}\n  {cores} logical processors\n\n");
5942        }
5943
5944        // RAM
5945        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5946            let total_kb: u64 = content
5947                .lines()
5948                .find(|l| l.starts_with("MemTotal:"))
5949                .and_then(|l| l.split_whitespace().nth(1))
5950                .and_then(|v| v.parse().ok())
5951                .unwrap_or(0);
5952            let total_gb = total_kb / 1_048_576;
5953            let _ = write!(out, "RAM: {total_gb} GB total\n\n");
5954        }
5955
5956        // GPU via lspci
5957        if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5958            let text = String::from_utf8_lossy(&o.stdout);
5959            let gpu_lines: Vec<&str> = text
5960                .lines()
5961                .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5962                .collect();
5963            if !gpu_lines.is_empty() {
5964                out.push_str("GPU(s):\n");
5965                for l in gpu_lines {
5966                    let _ = write!(out, "  {l}\n");
5967                }
5968                out.push('\n');
5969            }
5970        }
5971
5972        // DMI/BIOS info
5973        if let Ok(o) = Command::new("dmidecode")
5974            .args(["-t", "baseboard", "-t", "bios"])
5975            .output()
5976        {
5977            let text = String::from_utf8_lossy(&o.stdout);
5978            out.push_str("Motherboard/BIOS:\n");
5979            for line in text
5980                .lines()
5981                .filter(|l| {
5982                    l.contains("Manufacturer:")
5983                        || l.contains("Product Name:")
5984                        || l.contains("Version:")
5985                })
5986                .take(6)
5987            {
5988                let _ = write!(out, "  {}\n", line.trim());
5989            }
5990        }
5991    }
5992
5993    Ok(out.trim_end().to_string())
5994}
5995
5996// ── updates ───────────────────────────────────────────────────────────────────
5997
5998fn inspect_updates() -> Result<String, String> {
5999    let mut out = String::from("Host inspection: updates\n\n");
6000
6001    #[cfg(target_os = "windows")]
6002    {
6003        // Last installed update via COM
6004        let script = r#"
6005try {
6006    $sess = New-Object -ComObject Microsoft.Update.Session
6007    $searcher = $sess.CreateUpdateSearcher()
6008    $count = $searcher.GetTotalHistoryCount()
6009    if ($count -gt 0) {
6010        $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
6011        $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
6012    } else { "NONE|LAST_INSTALL" }
6013} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
6014"#;
6015        if let Ok(o) = Command::new("powershell")
6016            .args(["-NoProfile", "-Command", script])
6017            .output()
6018        {
6019            let raw = String::from_utf8_lossy(&o.stdout);
6020            let text = raw.trim();
6021            if text.starts_with("ERROR:") {
6022                out.push_str("Last update install: (unable to query)\n");
6023            } else if text.contains("NONE") {
6024                out.push_str("Last update install: No update history found\n");
6025            } else {
6026                let date = text.replace("|LAST_INSTALL", "");
6027                let _ = writeln!(out, "Last update install: {date}");
6028            }
6029        }
6030
6031        // Pending updates count
6032        let pending_script = r#"
6033try {
6034    $sess = New-Object -ComObject Microsoft.Update.Session
6035    $searcher = $sess.CreateUpdateSearcher()
6036    $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
6037    $results.Updates.Count.ToString() + "|PENDING"
6038} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
6039"#;
6040        if let Ok(o) = Command::new("powershell")
6041            .args(["-NoProfile", "-Command", pending_script])
6042            .output()
6043        {
6044            let raw = String::from_utf8_lossy(&o.stdout);
6045            let text = raw.trim();
6046            if text.starts_with("ERROR:") {
6047                out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
6048            } else {
6049                let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
6050                if count == 0 {
6051                    out.push_str("Pending updates: Up to date — no updates waiting\n");
6052                } else if count > 0 {
6053                    let _ = writeln!(out, "Pending updates: {count} update(s) available");
6054                    out.push_str(
6055                        "  → Open Windows Update (Settings > Windows Update) to install\n",
6056                    );
6057                }
6058            }
6059        }
6060
6061        // Windows Update service state
6062        let svc_script = r#"
6063$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
6064if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
6065"#;
6066        if let Ok(o) = Command::new("powershell")
6067            .args(["-NoProfile", "-Command", svc_script])
6068            .output()
6069        {
6070            let raw = String::from_utf8_lossy(&o.stdout);
6071            let status = raw.trim();
6072            let _ = writeln!(out, "Windows Update service: {status}");
6073        }
6074    }
6075
6076    #[cfg(not(target_os = "windows"))]
6077    {
6078        let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
6079        let mut found = false;
6080        if let Ok(o) = apt_out {
6081            let text = String::from_utf8_lossy(&o.stdout);
6082            let lines: Vec<&str> = text
6083                .lines()
6084                .filter(|l| l.contains('/') && !l.contains("Listing"))
6085                .collect();
6086            if !lines.is_empty() {
6087                let _ = write!(out, "{} package(s) can be upgraded (apt)\n", lines.len());
6088                out.push_str("  → Run: sudo apt upgrade\n");
6089                found = true;
6090            }
6091        }
6092        if !found {
6093            if let Ok(o) = Command::new("dnf")
6094                .args(["check-update", "--quiet"])
6095                .output()
6096            {
6097                let text = String::from_utf8_lossy(&o.stdout);
6098                let count = text
6099                    .lines()
6100                    .filter(|l| !l.is_empty() && !l.starts_with('!'))
6101                    .count();
6102                if count > 0 {
6103                    let _ = write!(out, "{count} package(s) can be upgraded (dnf)\n");
6104                    out.push_str("  → Run: sudo dnf upgrade\n");
6105                } else {
6106                    out.push_str("System is up to date.\n");
6107                }
6108            } else {
6109                out.push_str("Could not query package manager for updates.\n");
6110            }
6111        }
6112    }
6113
6114    Ok(out.trim_end().to_string())
6115}
6116
6117// ── security ──────────────────────────────────────────────────────────────────
6118
6119fn inspect_security() -> Result<String, String> {
6120    let mut out = String::from("Host inspection: security\n\n");
6121
6122    #[cfg(target_os = "windows")]
6123    {
6124        // Windows Defender status
6125        let defender_script = r#"
6126try {
6127    $status = Get-MpComputerStatus -ErrorAction Stop
6128    "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
6129} catch { "ERROR:" + $_.Exception.Message }
6130"#;
6131        if let Ok(o) = Command::new("powershell")
6132            .args(["-NoProfile", "-Command", defender_script])
6133            .output()
6134        {
6135            let raw = String::from_utf8_lossy(&o.stdout);
6136            let text = raw.trim();
6137            if text.starts_with("ERROR:") {
6138                let _ = writeln!(out, "Windows Defender: unable to query — {text}");
6139            } else {
6140                let get = |key: &str| -> String {
6141                    text.split('|')
6142                        .find(|s| s.starts_with(key))
6143                        .and_then(|s| s.split_once(':').map(|x| x.1))
6144                        .unwrap_or("unknown")
6145                        .to_string()
6146                };
6147                let rtp = get("RTP");
6148                let last_scan = {
6149                    // SCAN field has a colon in the time, so grab everything after "SCAN:"
6150                    text.split('|')
6151                        .find(|s| s.starts_with("SCAN:"))
6152                        .and_then(|s| s.get(5..))
6153                        .unwrap_or("unknown")
6154                        .to_string()
6155                };
6156                let def_ver = get("VER");
6157                let age_days: i64 = get("AGE").parse().unwrap_or(-1);
6158
6159                let rtp_label = if rtp == "True" {
6160                    "ENABLED"
6161                } else {
6162                    "DISABLED [!]"
6163                };
6164                let _ = writeln!(out, "Windows Defender real-time protection: {rtp_label}");
6165                let _ = writeln!(out, "Last quick scan: {last_scan}");
6166                let _ = writeln!(out, "Signature version: {def_ver}");
6167                if age_days >= 0 {
6168                    let freshness = if age_days == 0 {
6169                        "up to date".to_string()
6170                    } else if age_days <= 3 {
6171                        format!("{age_days} day(s) old — OK")
6172                    } else if age_days <= 7 {
6173                        format!("{age_days} day(s) old — consider updating")
6174                    } else {
6175                        format!("{age_days} day(s) old — [!] STALE, run Windows Update")
6176                    };
6177                    let _ = writeln!(out, "Signature age: {freshness}");
6178                }
6179                if rtp != "True" {
6180                    out.push_str(
6181                        "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
6182                    );
6183                    out.push_str(
6184                        "    → Open Windows Security > Virus & threat protection to re-enable.\n",
6185                    );
6186                }
6187            }
6188        }
6189
6190        out.push('\n');
6191
6192        // Windows Firewall state
6193        let fw_script = r#"
6194try {
6195    Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
6196} catch { "ERROR:" + $_.Exception.Message }
6197"#;
6198        if let Ok(o) = Command::new("powershell")
6199            .args(["-NoProfile", "-Command", fw_script])
6200            .output()
6201        {
6202            let raw = String::from_utf8_lossy(&o.stdout);
6203            let text = raw.trim();
6204            if !text.starts_with("ERROR:") && !text.is_empty() {
6205                out.push_str("Windows Firewall:\n");
6206                for line in text.lines() {
6207                    if let Some((name, enabled)) = line.split_once(':') {
6208                        let state = if enabled.trim() == "True" {
6209                            "ON"
6210                        } else {
6211                            "OFF [!]"
6212                        };
6213                        let _ = writeln!(out, "  {name}: {state}");
6214                    }
6215                }
6216                out.push('\n');
6217            }
6218        }
6219
6220        // Windows activation status
6221        let act_script = r#"
6222try {
6223    $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
6224    if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
6225} catch { "UNKNOWN" }
6226"#;
6227        if let Ok(o) = Command::new("powershell")
6228            .args(["-NoProfile", "-Command", act_script])
6229            .output()
6230        {
6231            let raw = String::from_utf8_lossy(&o.stdout);
6232            match raw.trim() {
6233                "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
6234                "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
6235                _ => out.push_str("Windows activation: Unable to determine\n"),
6236            }
6237        }
6238
6239        // UAC state
6240        let uac_script = r#"
6241$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
6242if ($val -eq 1) { "ON" } else { "OFF" }
6243"#;
6244        if let Ok(o) = Command::new("powershell")
6245            .args(["-NoProfile", "-Command", uac_script])
6246            .output()
6247        {
6248            let raw = String::from_utf8_lossy(&o.stdout);
6249            let state = raw.trim();
6250            let label = if state == "ON" {
6251                "Enabled"
6252            } else {
6253                "DISABLED [!] — recommended to re-enable via secpol.msc"
6254            };
6255            let _ = writeln!(out, "UAC (User Account Control): {label}");
6256        }
6257    }
6258
6259    #[cfg(not(target_os = "windows"))]
6260    {
6261        if let Ok(o) = Command::new("ufw").arg("status").output() {
6262            let text = String::from_utf8_lossy(&o.stdout);
6263            let _ = write!(out, "UFW: {}\n", text.lines().next().unwrap_or("unknown"));
6264        }
6265        if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
6266            if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
6267                let _ = write!(out, "{line}\n");
6268            }
6269        }
6270    }
6271
6272    Ok(out.trim_end().to_string())
6273}
6274
6275// ── pending_reboot ────────────────────────────────────────────────────────────
6276
6277fn inspect_pending_reboot() -> Result<String, String> {
6278    let mut out = String::from("Host inspection: pending_reboot\n\n");
6279
6280    #[cfg(target_os = "windows")]
6281    {
6282        let script = r#"
6283$reasons = @()
6284if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
6285    $reasons += "Windows Update requires a restart"
6286}
6287if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
6288    $reasons += "Windows component install/update requires a restart"
6289}
6290$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
6291if ($pfro -and $pfro.PendingFileRenameOperations) {
6292    $reasons += "Pending file rename operations (driver or system file replacement)"
6293}
6294if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
6295"#;
6296        let output = Command::new("powershell")
6297            .args(["-NoProfile", "-Command", script])
6298            .output()
6299            .map_err(|e| format!("pending_reboot: {e}"))?;
6300
6301        let raw = String::from_utf8_lossy(&output.stdout);
6302        let text = raw.trim();
6303
6304        if text == "NO_REBOOT_NEEDED" {
6305            out.push_str("No restart required — system is up to date and stable.\n");
6306        } else if text.is_empty() {
6307            out.push_str("Could not determine reboot status.\n");
6308        } else {
6309            out.push_str("[!] A system restart is pending:\n\n");
6310            for reason in text.split("|REASON|") {
6311                let _ = writeln!(out, "  • {}", reason.trim());
6312            }
6313            out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
6314        }
6315    }
6316
6317    #[cfg(not(target_os = "windows"))]
6318    {
6319        if std::path::Path::new("/var/run/reboot-required").exists() {
6320            out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
6321            if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
6322                out.push_str("Packages requiring restart:\n");
6323                for p in pkgs.lines().take(10) {
6324                    let _ = write!(out, "  • {p}\n");
6325                }
6326            }
6327        } else {
6328            out.push_str("No restart required.\n");
6329        }
6330    }
6331
6332    Ok(out.trim_end().to_string())
6333}
6334
6335// ── disk_health ───────────────────────────────────────────────────────────────
6336
6337fn inspect_disk_health() -> Result<String, String> {
6338    let mut out = String::from("Host inspection: disk_health\n\n");
6339
6340    #[cfg(target_os = "windows")]
6341    {
6342        let script = r#"
6343try {
6344    $disks = Get-PhysicalDisk -ErrorAction Stop
6345    foreach ($d in $disks) {
6346        $size_gb = [math]::Round($d.Size / 1GB, 0)
6347        $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
6348    }
6349} catch { "ERROR:" + $_.Exception.Message }
6350"#;
6351        let output = Command::new("powershell")
6352            .args(["-NoProfile", "-Command", script])
6353            .output()
6354            .map_err(|e| format!("disk_health: {e}"))?;
6355
6356        let raw = String::from_utf8_lossy(&output.stdout);
6357        let text = raw.trim();
6358
6359        if text.starts_with("ERROR:") {
6360            let _ = writeln!(out, "Unable to query disk health: {text}");
6361            out.push_str("This may require running as administrator.\n");
6362        } else if text.is_empty() {
6363            out.push_str("No physical disks found.\n");
6364        } else {
6365            out.push_str("Physical Drive Health:\n\n");
6366            for line in text.lines() {
6367                let mut it = line.splitn(5, '|');
6368                if let (Some(name), Some(media), Some(size), Some(health)) =
6369                    (it.next(), it.next(), it.next(), it.next())
6370                {
6371                    let op_status = it.next().unwrap_or("");
6372                    let health_label = match health.trim() {
6373                        "Healthy" => "OK",
6374                        "Warning" => "[!] WARNING",
6375                        "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
6376                        other => other,
6377                    };
6378                    let _ = writeln!(out, "  {name}");
6379                    let _ = writeln!(out, "    Type: {media} | Size: {size}");
6380                    let _ = writeln!(out, "    Health: {health_label}");
6381                    if !op_status.is_empty() {
6382                        let _ = writeln!(out, "    Status: {op_status}");
6383                    }
6384                    out.push('\n');
6385                }
6386            }
6387        }
6388
6389        // SMART failure prediction (best-effort, may need admin)
6390        let smart_script = r#"
6391try {
6392    Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
6393        ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
6394} catch { "" }
6395"#;
6396        if let Ok(o) = Command::new("powershell")
6397            .args(["-NoProfile", "-Command", smart_script])
6398            .output()
6399        {
6400            let raw2 = String::from_utf8_lossy(&o.stdout);
6401            let text2 = raw2.trim();
6402            if !text2.is_empty() {
6403                let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
6404                if failures.is_empty() {
6405                    out.push_str("SMART failure prediction: No failures predicted\n");
6406                } else {
6407                    out.push_str("[!!] SMART failure predicted on one or more drives:\n");
6408                    for f in failures {
6409                        let name = f.split('|').next().unwrap_or(f);
6410                        let _ = writeln!(out, "  • {name}");
6411                    }
6412                    out.push_str(
6413                        "\nBack up your data immediately and replace the failing drive.\n",
6414                    );
6415                }
6416            }
6417        }
6418    }
6419
6420    #[cfg(not(target_os = "windows"))]
6421    {
6422        if let Ok(o) = Command::new("lsblk")
6423            .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
6424            .output()
6425        {
6426            let text = String::from_utf8_lossy(&o.stdout);
6427            out.push_str("Block devices:\n");
6428            out.push_str(text.trim());
6429            out.push('\n');
6430        }
6431        if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
6432            let devices = String::from_utf8_lossy(&scan.stdout);
6433            for dev_line in devices.lines().take(4) {
6434                let dev = dev_line.split_whitespace().next().unwrap_or("");
6435                if dev.is_empty() {
6436                    continue;
6437                }
6438                if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
6439                    let health = String::from_utf8_lossy(&o.stdout);
6440                    if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
6441                    {
6442                        let _ = write!(out, "{dev}: {}\n", line.trim());
6443                    }
6444                }
6445            }
6446        } else {
6447            out.push_str("(install smartmontools for SMART health data)\n");
6448        }
6449    }
6450
6451    Ok(out.trim_end().to_string())
6452}
6453
6454// ── battery ───────────────────────────────────────────────────────────────────
6455
6456fn inspect_battery() -> Result<String, String> {
6457    let mut out = String::from("Host inspection: battery\n\n");
6458
6459    #[cfg(target_os = "windows")]
6460    {
6461        let script = r#"
6462try {
6463    $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
6464    if (-not $bats) { "NO_BATTERY"; exit }
6465    
6466    # Modern Battery Health (Cycle count + Capacity health)
6467    $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
6468    $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue 
6469    $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
6470
6471    foreach ($b in $bats) {
6472        $state = switch ($b.BatteryStatus) {
6473            1 { "Discharging" }
6474            2 { "AC Power (Fully Charged)" }
6475            3 { "AC Power (Charging)" }
6476            default { "Status $($b.BatteryStatus)" }
6477        }
6478        
6479        $cycles = if ($status) { $status.CycleCount } else { "unknown" }
6480        $health = if ($static -and $full) {
6481             [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
6482        } else { "unknown" }
6483
6484        $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
6485    }
6486} catch { "ERROR:" + $_.Exception.Message }
6487"#;
6488        let output = Command::new("powershell")
6489            .args(["-NoProfile", "-Command", script])
6490            .output()
6491            .map_err(|e| format!("battery: {e}"))?;
6492
6493        let raw = String::from_utf8_lossy(&output.stdout);
6494        let text = raw.trim();
6495
6496        if text == "NO_BATTERY" {
6497            out.push_str("No battery detected — desktop or AC-only system.\n");
6498            return Ok(out.trim_end().to_string());
6499        }
6500        if text.starts_with("ERROR:") {
6501            let _ = writeln!(out, "Unable to query battery: {text}");
6502            return Ok(out.trim_end().to_string());
6503        }
6504
6505        for line in text.lines() {
6506            let mut it = line.splitn(6, '|');
6507            if let (Some(name), Some(p1), Some(state), Some(cycles), Some(health)) =
6508                (it.next(), it.next(), it.next(), it.next(), it.next())
6509            {
6510                let charge: i64 = p1.parse().unwrap_or(-1);
6511
6512                let _ = writeln!(out, "Battery: {name}");
6513                if charge >= 0 {
6514                    let bar_filled = (charge as usize * 20) / 100;
6515                    let _ = writeln!(
6516                        out,
6517                        "  Charge: [{}{}] {}%",
6518                        "#".repeat(bar_filled),
6519                        ".".repeat(20 - bar_filled),
6520                        charge
6521                    );
6522                }
6523                let _ = writeln!(out, "  Status: {state}");
6524                let _ = writeln!(out, "  Cycles: {cycles}");
6525                let _ = write!(out, "  Health: {health}% (Actual vs Design Capacity)\n\n");
6526            }
6527        }
6528    }
6529
6530    #[cfg(not(target_os = "windows"))]
6531    {
6532        let power_path = std::path::Path::new("/sys/class/power_supply");
6533        let mut found = false;
6534        if power_path.exists() {
6535            if let Ok(entries) = std::fs::read_dir(power_path) {
6536                for entry in entries.flatten() {
6537                    let p = entry.path();
6538                    if let Ok(t) = std::fs::read_to_string(p.join("type")) {
6539                        if t.trim() == "Battery" {
6540                            found = true;
6541                            let name = p
6542                                .file_name()
6543                                .unwrap_or_default()
6544                                .to_string_lossy()
6545                                .to_string();
6546                            let _ = write!(out, "Battery: {name}\n");
6547                            let read = |f: &str| {
6548                                std::fs::read_to_string(p.join(f))
6549                                    .ok()
6550                                    .map(|s| s.trim().to_string())
6551                            };
6552                            if let Some(cap) = read("capacity") {
6553                                let _ = write!(out, "  Charge: {cap}%\n");
6554                            }
6555                            if let Some(status) = read("status") {
6556                                let _ = write!(out, "  Status: {status}\n");
6557                            }
6558                            if let (Some(full), Some(design)) =
6559                                (read("energy_full"), read("energy_full_design"))
6560                            {
6561                                if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
6562                                {
6563                                    if d > 0.0 {
6564                                        let _ = write!(
6565                                            out,
6566                                            "  Wear level: {:.1}% of design capacity\n",
6567                                            (f / d) * 100.0
6568                                        );
6569                                    }
6570                                }
6571                            }
6572                        }
6573                    }
6574                }
6575            }
6576        }
6577        if !found {
6578            out.push_str("No battery found.\n");
6579        }
6580    }
6581
6582    Ok(out.trim_end().to_string())
6583}
6584
6585// ── recent_crashes ────────────────────────────────────────────────────────────
6586
6587fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
6588    let mut out = String::from("Host inspection: recent_crashes\n\n");
6589    let n = max_entries.clamp(1, 30);
6590
6591    #[cfg(target_os = "windows")]
6592    {
6593        // BSODs / unexpected shutdowns (EventID 41 = kernel power, 1001 = BugCheck)
6594        let bsod_script = format!(
6595            r#"
6596try {{
6597    $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
6598    if ($events) {{
6599        $events | ForEach-Object {{
6600            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6601        }}
6602    }} else {{ "NO_BSOD" }}
6603}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6604        );
6605
6606        if let Ok(o) = Command::new("powershell")
6607            .args(["-NoProfile", "-Command", &bsod_script])
6608            .output()
6609        {
6610            let raw = String::from_utf8_lossy(&o.stdout);
6611            let text = raw.trim();
6612            if text == "NO_BSOD" {
6613                out.push_str("System crashes (BSOD/kernel): None in recent history\n");
6614            } else if text.starts_with("ERROR:") {
6615                out.push_str("System crashes: unable to query\n");
6616            } else {
6617                out.push_str("System crashes / unexpected shutdowns:\n");
6618                for line in text.lines() {
6619                    let mut it = line.splitn(3, '|');
6620                    if let (Some(time), Some(id), Some(msg)) = (it.next(), it.next(), it.next()) {
6621                        let label = if id == "41" {
6622                            "Unexpected shutdown"
6623                        } else {
6624                            "BSOD (BugCheck)"
6625                        };
6626                        let _ = writeln!(out, "  [{time}] {label}: {msg}");
6627                    }
6628                }
6629                out.push('\n');
6630            }
6631        }
6632
6633        // Application crashes (EventID 1000 = app crash, 1002 = app hang)
6634        let app_script = format!(
6635            r#"
6636try {{
6637    $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
6638    if ($crashes) {{
6639        $crashes | ForEach-Object {{
6640            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
6641        }}
6642    }} else {{ "NO_CRASHES" }}
6643}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
6644        );
6645
6646        if let Ok(o) = Command::new("powershell")
6647            .args(["-NoProfile", "-Command", &app_script])
6648            .output()
6649        {
6650            let raw = String::from_utf8_lossy(&o.stdout);
6651            let text = raw.trim();
6652            if text == "NO_CRASHES" {
6653                out.push_str("Application crashes: None in recent history\n");
6654            } else if text.starts_with("ERROR_APP:") {
6655                out.push_str("Application crashes: unable to query\n");
6656            } else {
6657                out.push_str("Application crashes:\n");
6658                for line in text.lines().take(n) {
6659                    let mut it = line.splitn(2, '|');
6660                    if let (Some(a), Some(b)) = (it.next(), it.next()) {
6661                        let _ = writeln!(out, "  [{}] {}", a, b);
6662                    }
6663                }
6664            }
6665        }
6666    }
6667
6668    #[cfg(not(target_os = "windows"))]
6669    {
6670        let n_str = n.to_string();
6671        if let Ok(o) = Command::new("journalctl")
6672            .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
6673            .output()
6674        {
6675            let text = String::from_utf8_lossy(&o.stdout);
6676            let trimmed = text.trim();
6677            if trimmed.is_empty() || trimmed.contains("No entries") {
6678                out.push_str("No kernel panics or critical crashes found.\n");
6679            } else {
6680                out.push_str("Kernel critical events:\n");
6681                out.push_str(trimmed);
6682                out.push('\n');
6683            }
6684        }
6685        if let Ok(o) = Command::new("coredumpctl")
6686            .args(["list", "--no-pager"])
6687            .output()
6688        {
6689            let text = String::from_utf8_lossy(&o.stdout);
6690            let count = text
6691                .lines()
6692                .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
6693                .count();
6694            if count > 0 {
6695                let _ = write!(
6696                    out,
6697                    "\nCore dumps on file: {count}\n  → Run: coredumpctl list\n"
6698                );
6699            }
6700        }
6701    }
6702
6703    Ok(out.trim_end().to_string())
6704}
6705
6706// ── scheduled_tasks ───────────────────────────────────────────────────────────
6707
6708fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
6709    let mut out = String::from("Host inspection: scheduled_tasks\n\n");
6710    let n = max_entries.clamp(1, 30);
6711
6712    #[cfg(target_os = "windows")]
6713    {
6714        let script = format!(
6715            r#"
6716try {{
6717    $tasks = Get-ScheduledTask -ErrorAction Stop |
6718        Where-Object {{ $_.State -ne 'Disabled' }} |
6719        ForEach-Object {{
6720            $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
6721            $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
6722                $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
6723            }} else {{ "never" }}
6724            $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
6725            $exec = ($_.Actions | Select-Object -First 1).Execute
6726            if (-not $exec) {{ $exec = "(no exec)" }}
6727            $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
6728        }}
6729    $tasks | Select-Object -First {n}
6730}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6731        );
6732
6733        let output = Command::new("powershell")
6734            .args(["-NoProfile", "-Command", &script])
6735            .output()
6736            .map_err(|e| format!("scheduled_tasks: {e}"))?;
6737
6738        let raw = String::from_utf8_lossy(&output.stdout);
6739        let text = raw.trim();
6740
6741        if text.starts_with("ERROR:") {
6742            let _ = writeln!(out, "Unable to query scheduled tasks: {text}");
6743        } else if text.is_empty() {
6744            out.push_str("No active scheduled tasks found.\n");
6745        } else {
6746            let _ = write!(out, "Active scheduled tasks (up to {n}):\n\n");
6747            for line in text.lines() {
6748                let mut it = line.splitn(6, '|');
6749                if let (Some(name), Some(path), Some(state), Some(last), Some(res)) =
6750                    (it.next(), it.next(), it.next(), it.next(), it.next())
6751                {
6752                    let exec = it.next().unwrap_or("").trim();
6753                    let display_path = path.trim_matches('\\');
6754                    let display_path = if display_path.is_empty() {
6755                        "Root"
6756                    } else {
6757                        display_path
6758                    };
6759                    let _ = writeln!(out, "  {name} [{display_path}]");
6760                    let _ = writeln!(out, "    State: {state} | Last run: {last} | Result: {res}");
6761                    if !exec.is_empty() && exec != "(no exec)" {
6762                        let short = if exec.len() > 80 {
6763                            safe_head(exec, 80)
6764                        } else {
6765                            exec
6766                        };
6767                        let _ = writeln!(out, "    Runs: {short}");
6768                    }
6769                }
6770            }
6771        }
6772    }
6773
6774    #[cfg(not(target_os = "windows"))]
6775    {
6776        if let Ok(o) = Command::new("systemctl")
6777            .args(["list-timers", "--no-pager", "--all"])
6778            .output()
6779        {
6780            let text = String::from_utf8_lossy(&o.stdout);
6781            out.push_str("Systemd timers:\n");
6782            for l in text
6783                .lines()
6784                .filter(|l| {
6785                    !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
6786                })
6787                .take(n)
6788            {
6789                let _ = write!(out, "  {l}\n");
6790            }
6791            out.push('\n');
6792        }
6793        if let Ok(o) = Command::new("crontab").arg("-l").output() {
6794            let text = String::from_utf8_lossy(&o.stdout);
6795            let jobs: Vec<&str> = text
6796                .lines()
6797                .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
6798                .collect();
6799            if !jobs.is_empty() {
6800                out.push_str("User crontab:\n");
6801                for j in jobs.iter().take(n) {
6802                    let _ = write!(out, "  {j}\n");
6803                }
6804            }
6805        }
6806    }
6807
6808    Ok(out.trim_end().to_string())
6809}
6810
6811// ── dev_conflicts ─────────────────────────────────────────────────────────────
6812
6813fn inspect_dev_conflicts() -> Result<String, String> {
6814    let mut out = String::from("Host inspection: dev_conflicts\n\n");
6815    let mut conflicts: Vec<String> = Vec::with_capacity(4);
6816    let mut notes: Vec<String> = Vec::with_capacity(4);
6817
6818    // ── Node.js / version managers ────────────────────────────────────────────
6819    {
6820        let node_ver = Command::new("node")
6821            .arg("--version")
6822            .output()
6823            .ok()
6824            .and_then(|o| String::from_utf8(o.stdout).ok())
6825            .map(|s| s.trim().to_string());
6826        let nvm_active = Command::new("nvm")
6827            .arg("current")
6828            .output()
6829            .ok()
6830            .and_then(|o| String::from_utf8(o.stdout).ok())
6831            .map(|s| s.trim().to_string())
6832            .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6833        let fnm_active = Command::new("fnm")
6834            .arg("current")
6835            .output()
6836            .ok()
6837            .and_then(|o| String::from_utf8(o.stdout).ok())
6838            .map(|s| s.trim().to_string())
6839            .filter(|s| !s.is_empty() && !s.contains("none"));
6840        let volta_active = Command::new("volta")
6841            .args(["which", "node"])
6842            .output()
6843            .ok()
6844            .and_then(|o| String::from_utf8(o.stdout).ok())
6845            .map(|s| s.trim().to_string())
6846            .filter(|s| !s.is_empty());
6847
6848        out.push_str("Node.js:\n");
6849        if let Some(ref v) = node_ver {
6850            let _ = writeln!(out, "  Active: {v}");
6851        } else {
6852            out.push_str("  Not installed\n");
6853        }
6854        let managers: Vec<&str> = [
6855            nvm_active.as_deref(),
6856            fnm_active.as_deref(),
6857            volta_active.as_deref(),
6858        ]
6859        .iter()
6860        .filter_map(|x| *x)
6861        .collect();
6862        if managers.len() > 1 {
6863            conflicts.push("Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts.".to_string());
6864        } else if !managers.is_empty() {
6865            let _ = writeln!(out, "  Version manager: {}", managers[0]);
6866        }
6867        out.push('\n');
6868    }
6869
6870    // ── Python ────────────────────────────────────────────────────────────────
6871    {
6872        let py3 = Command::new("python3")
6873            .arg("--version")
6874            .output()
6875            .ok()
6876            .and_then(|o| {
6877                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6878                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6879                let v = if stdout.is_empty() { stderr } else { stdout };
6880                if v.is_empty() {
6881                    None
6882                } else {
6883                    Some(v)
6884                }
6885            });
6886        let py = Command::new("python")
6887            .arg("--version")
6888            .output()
6889            .ok()
6890            .and_then(|o| {
6891                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6892                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6893                let v = if stdout.is_empty() { stderr } else { stdout };
6894                if v.is_empty() {
6895                    None
6896                } else {
6897                    Some(v)
6898                }
6899            });
6900        let pyenv = Command::new("pyenv")
6901            .arg("version")
6902            .output()
6903            .ok()
6904            .and_then(|o| String::from_utf8(o.stdout).ok())
6905            .map(|s| s.trim().to_string())
6906            .filter(|s| !s.is_empty());
6907        let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6908
6909        out.push_str("Python:\n");
6910        match (&py3, &py) {
6911            (Some(v3), Some(v)) if v3 != v => {
6912                let _ = write!(out, "  python3: {v3}\n  python:  {v}\n");
6913                if v.contains("2.") {
6914                    conflicts.push(
6915                        "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6916                    );
6917                } else {
6918                    notes.push(
6919                        "python and python3 resolve to different minor versions.".to_string(),
6920                    );
6921                }
6922            }
6923            (Some(v3), None) => {
6924                let _ = writeln!(out, "  python3: {v3}");
6925            }
6926            (None, Some(v)) => {
6927                let _ = writeln!(out, "  python: {v}");
6928            }
6929            (Some(v3), Some(_)) => {
6930                let _ = writeln!(out, "  {v3}");
6931            }
6932            (None, None) => out.push_str("  Not installed\n"),
6933        }
6934        if let Some(ref pe) = pyenv {
6935            let _ = writeln!(out, "  pyenv: {pe}");
6936        }
6937        if let Some(env) = conda_env {
6938            if env == "base" {
6939                notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6940            } else {
6941                let _ = writeln!(out, "  conda env: {env}");
6942            }
6943        }
6944        out.push('\n');
6945    }
6946
6947    // ── Rust / Cargo ──────────────────────────────────────────────────────────
6948    {
6949        let toolchain = Command::new("rustup")
6950            .args(["show", "active-toolchain"])
6951            .output()
6952            .ok()
6953            .and_then(|o| String::from_utf8(o.stdout).ok())
6954            .map(|s| s.trim().to_string())
6955            .filter(|s| !s.is_empty());
6956        let cargo_ver = Command::new("cargo")
6957            .arg("--version")
6958            .output()
6959            .ok()
6960            .and_then(|o| String::from_utf8(o.stdout).ok())
6961            .map(|s| s.trim().to_string());
6962        let rustc_ver = Command::new("rustc")
6963            .arg("--version")
6964            .output()
6965            .ok()
6966            .and_then(|o| String::from_utf8(o.stdout).ok())
6967            .map(|s| s.trim().to_string());
6968
6969        out.push_str("Rust:\n");
6970        if let Some(ref t) = toolchain {
6971            let _ = writeln!(out, "  Active toolchain: {t}");
6972        }
6973        if let Some(ref c) = cargo_ver {
6974            let _ = writeln!(out, "  {c}");
6975        }
6976        if let Some(ref r) = rustc_ver {
6977            let _ = writeln!(out, "  {r}");
6978        }
6979        if cargo_ver.is_none() && rustc_ver.is_none() {
6980            out.push_str("  Not installed\n");
6981        }
6982
6983        // Detect system rust that might shadow rustup
6984        #[cfg(not(target_os = "windows"))]
6985        if let Ok(o) = Command::new("which").arg("rustc").output() {
6986            let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6987            if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6988                conflicts.push(format!(
6989                    "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6990                ));
6991            }
6992        }
6993        out.push('\n');
6994    }
6995
6996    // ── Git ───────────────────────────────────────────────────────────────────
6997    {
6998        let git_ver = Command::new("git")
6999            .arg("--version")
7000            .output()
7001            .ok()
7002            .and_then(|o| String::from_utf8(o.stdout).ok())
7003            .map(|s| s.trim().to_string());
7004        out.push_str("Git:\n");
7005        if let Some(ref v) = git_ver {
7006            let _ = writeln!(out, "  {v}");
7007            let email = Command::new("git")
7008                .args(["config", "--global", "user.email"])
7009                .output()
7010                .ok()
7011                .and_then(|o| String::from_utf8(o.stdout).ok())
7012                .map(|s| s.trim().to_string());
7013            if let Some(ref e) = email {
7014                if e.is_empty() {
7015                    notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
7016                } else {
7017                    let _ = writeln!(out, "  user.email: {e}");
7018                }
7019            }
7020            let gpg_sign = Command::new("git")
7021                .args(["config", "--global", "commit.gpgsign"])
7022                .output()
7023                .ok()
7024                .and_then(|o| String::from_utf8(o.stdout).ok())
7025                .map(|s| s.trim().to_string());
7026            if gpg_sign.as_deref() == Some("true") {
7027                let key = Command::new("git")
7028                    .args(["config", "--global", "user.signingkey"])
7029                    .output()
7030                    .ok()
7031                    .and_then(|o| String::from_utf8(o.stdout).ok())
7032                    .map(|s| s.trim().to_string());
7033                if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
7034                    conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
7035                }
7036            }
7037        } else {
7038            out.push_str("  Not installed\n");
7039        }
7040        out.push('\n');
7041    }
7042
7043    // ── PATH duplicates ───────────────────────────────────────────────────────
7044    {
7045        let path_env = std::env::var("PATH").unwrap_or_default();
7046        let sep = if cfg!(windows) { ';' } else { ':' };
7047        let mut seen = HashSet::new();
7048        let mut dupes: Vec<String> = Vec::new();
7049        for p in path_env.split(sep) {
7050            let norm = p.trim().to_lowercase();
7051            if !norm.is_empty() && !seen.insert(norm) {
7052                dupes.push(p.to_string());
7053            }
7054        }
7055        if !dupes.is_empty() {
7056            let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
7057            notes.push(format!(
7058                "Duplicate PATH entries: {} {}",
7059                shown.join(", "),
7060                if dupes.len() > 3 {
7061                    format!("+{} more", dupes.len() - 3)
7062                } else {
7063                    String::new()
7064                }
7065            ));
7066        }
7067    }
7068
7069    // ── Summary ───────────────────────────────────────────────────────────────
7070    if conflicts.is_empty() && notes.is_empty() {
7071        out.push_str("No conflicts detected — dev environment looks clean.\n");
7072    } else {
7073        if !conflicts.is_empty() {
7074            out.push_str("CONFLICTS:\n");
7075            for c in &conflicts {
7076                let _ = writeln!(out, "  [!] {c}");
7077            }
7078            out.push('\n');
7079        }
7080        if !notes.is_empty() {
7081            out.push_str("NOTES:\n");
7082            for n in &notes {
7083                let _ = writeln!(out, "  [-] {n}");
7084            }
7085        }
7086    }
7087
7088    Ok(out.trim_end().to_string())
7089}
7090
7091// ── connectivity ──────────────────────────────────────────────────────────────
7092
7093async fn inspect_public_ip() -> Result<String, String> {
7094    let mut out = String::from("Host inspection: public_ip\n\n");
7095
7096    let client = reqwest::Client::builder()
7097        .timeout(std::time::Duration::from_secs(5))
7098        .build()
7099        .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
7100
7101    match client.get("https://api.ipify.org?format=json").send().await {
7102        Ok(resp) => {
7103            if let Ok(json) = resp.json::<serde_json::Value>().await {
7104                let ip = json.get("ip").and_then(|v| v.as_str()).unwrap_or("Unknown");
7105                let _ = writeln!(out, "Public IP: {}", ip);
7106
7107                // Geo info
7108                if let Ok(geo_resp) = client
7109                    .get(format!("http://ip-api.com/json/{}", ip))
7110                    .send()
7111                    .await
7112                {
7113                    if let Ok(geo_json) = geo_resp.json::<serde_json::Value>().await {
7114                        if let (Some(city), Some(region), Some(country), Some(isp)) = (
7115                            geo_json.get("city").and_then(|v| v.as_str()),
7116                            geo_json.get("regionName").and_then(|v| v.as_str()),
7117                            geo_json.get("country").and_then(|v| v.as_str()),
7118                            geo_json.get("isp").and_then(|v| v.as_str()),
7119                        ) {
7120                            let _ = writeln!(out, "Location:  {}, {} ({})", city, region, country);
7121                            let _ = writeln!(out, "ISP:       {}", isp);
7122                        }
7123                    }
7124                }
7125            } else {
7126                out.push_str("Error: Failed to parse public IP response.\n");
7127            }
7128        }
7129        Err(e) => {
7130            let _ = writeln!(
7131                out,
7132                "Error: Failed to fetch public IP ({}). Check internet connectivity.",
7133                e
7134            );
7135        }
7136    }
7137
7138    Ok(out)
7139}
7140
7141fn inspect_ssl_cert(host: &str) -> Result<String, String> {
7142    let mut out = format!("Host inspection: ssl_cert (Target: {})\n\n", host);
7143
7144    #[cfg(target_os = "windows")]
7145    {
7146        use std::process::Command;
7147        let escaped_host = ps_escape_single_quoted(host);
7148        let script = format!(
7149            r#"$domain = '{escaped_host}'
7150try {{
7151    $tcpClient = New-Object System.Net.Sockets.TcpClient($domain, 443)
7152    $sslStream = New-Object System.Net.Security.SslStream($tcpClient.GetStream(), $false, ({{ $true }} -as [System.Net.Security.RemoteCertificateValidationCallback]))
7153    $sslStream.AuthenticateAsClient($domain)
7154    $cert = $sslStream.RemoteCertificate
7155    $tcpClient.Close()
7156    if ($cert) {{
7157        $cert2 = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($cert)
7158        $cert2 | Select-Object Subject, Issuer, NotBefore, NotAfter, Thumbprint, SerialNumber | ConvertTo-Json
7159    }} else {{
7160        "null"
7161    }}
7162}} catch {{
7163    "ERROR:" + $_.Exception.Message
7164}}"#
7165        );
7166
7167        let ps_out = Command::new("powershell")
7168            .args(["-NoProfile", "-NonInteractive", "-Command", &script])
7169            .output()
7170            .map_err(|e| format!("powershell launch failed: {e}"))?;
7171
7172        let text = String::from_utf8_lossy(&ps_out.stdout).trim().to_string();
7173        if text.starts_with("ERROR:") {
7174            let _ = writeln!(out, "Error: {}", text.trim_start_matches("ERROR:"));
7175        } else if text == "null" || text.is_empty() {
7176            out.push_str("Error: Could not retrieve certificate. Target may be unreachable or not using SSL.\n");
7177        } else if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
7178            if let Some(obj) = json.as_object() {
7179                for (k, v) in obj {
7180                    let val_str = v.as_str().unwrap_or("");
7181                    let _ = writeln!(out, "{:<12}: {}", k, val_str);
7182                }
7183
7184                if let Some(not_after_raw) = obj.get("NotAfter").and_then(|v| v.as_str()) {
7185                    if not_after_raw.starts_with("/Date(") {
7186                        let ts = not_after_raw
7187                            .trim_start_matches("/Date(")
7188                            .trim_end_matches(")/")
7189                            .parse::<i64>()
7190                            .unwrap_or(0);
7191                        let expiry =
7192                            chrono::DateTime::from_timestamp(ts / 1000, 0).unwrap_or_default();
7193                        let now = chrono::Utc::now();
7194                        let days_left = expiry.signed_duration_since(now).num_days();
7195                        if days_left < 0 {
7196                            out.push_str("\nSTATUS: [!!] EXPIRED\n");
7197                        } else if days_left < 30 {
7198                            let _ = write!(
7199                                out,
7200                                "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
7201                                days_left
7202                            );
7203                        } else {
7204                            let _ = write!(out, "\nSTATUS: Valid ({} days left)\n", days_left);
7205                        }
7206                    } else if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(not_after_raw) {
7207                        let now = chrono::Utc::now();
7208                        let days_left = expiry.signed_duration_since(now).num_days();
7209                        if days_left < 0 {
7210                            out.push_str("\nSTATUS: [!!] EXPIRED\n");
7211                        } else if days_left < 30 {
7212                            let _ = write!(
7213                                out,
7214                                "\nSTATUS: [!] EXPIRING SOON ({} days left)\n",
7215                                days_left
7216                            );
7217                        } else {
7218                            let _ = write!(out, "\nSTATUS: Valid ({} days left)\n", days_left);
7219                        }
7220                    }
7221                }
7222            }
7223        } else {
7224            let _ = writeln!(out, "Raw Output: {}", text);
7225        }
7226    }
7227
7228    #[cfg(not(target_os = "windows"))]
7229    {
7230        out.push_str(
7231            "Note: Deep SSL inspection currently optimized for Windows (PowerShell/SslStream).\n",
7232        );
7233    }
7234
7235    Ok(out)
7236}
7237
7238async fn inspect_data_audit(path: PathBuf, _max_entries: usize) -> Result<String, String> {
7239    let mut out = format!("Host inspection: data_audit (Path: {:?})\n\n", path);
7240
7241    if !path.exists() {
7242        return Err(format!("File not found: {:?}", path));
7243    }
7244    if !path.is_file() {
7245        return Err(format!("Not a file: {:?}", path));
7246    }
7247
7248    let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
7249    let _ = writeln!(
7250        out,
7251        "File Size: {} bytes ({:.2} MB)",
7252        file_size,
7253        file_size as f64 / 1_048_576.0
7254    );
7255
7256    let ext = path
7257        .extension()
7258        .and_then(|s| s.to_str())
7259        .unwrap_or("")
7260        .to_lowercase();
7261    let _ = write!(out, "Format:    {}\n\n", ext.to_uppercase());
7262
7263    match ext.as_str() {
7264        "csv" | "tsv" | "txt" | "log" => {
7265            let content = std::fs::read_to_string(&path)
7266                .map_err(|e| format!("Failed to read file: {}", e))?;
7267            let lines: Vec<&str> = content.lines().collect();
7268            let _ = writeln!(out, "Row Count: {} (total lines)", lines.len());
7269
7270            if let Some(header) = lines.first() {
7271                out.push_str("Columns (Guessed from header):\n");
7272                let delimiter = if ext == "tsv" {
7273                    "\t"
7274                } else if header.contains(',') {
7275                    ","
7276                } else {
7277                    " "
7278                };
7279                for (i, col) in header.split(delimiter).map(|s| s.trim()).enumerate() {
7280                    let _ = writeln!(out, "  {}. {}", i + 1, col);
7281                }
7282            }
7283
7284            out.push_str("\nSample Data (First 5 rows):\n");
7285            for line in lines.iter().take(6) {
7286                let _ = writeln!(out, "  {}", line);
7287            }
7288        }
7289        "json" => {
7290            let content = std::fs::read_to_string(&path)
7291                .map_err(|e| format!("Failed to read file: {}", e))?;
7292            if let Ok(json) = serde_json::from_str::<Value>(&content) {
7293                if let Some(arr) = json.as_array() {
7294                    let _ = writeln!(out, "Record Count: {}", arr.len());
7295                    if let Some(first) = arr.first() {
7296                        if let Some(obj) = first.as_object() {
7297                            out.push_str("Fields (from first record):\n");
7298                            for k in obj.keys() {
7299                                let _ = writeln!(out, "  - {}", k);
7300                            }
7301                        }
7302                    }
7303                    out.push_str("\nSample Record:\n");
7304                    out.push_str(&serde_json::to_string_pretty(&arr.first()).unwrap_or_default());
7305                } else if let Some(obj) = json.as_object() {
7306                    out.push_str("Top-level Keys:\n");
7307                    for k in obj.keys() {
7308                        let _ = writeln!(out, "  - {}", k);
7309                    }
7310                }
7311            } else {
7312                out.push_str("Error: Failed to parse as JSON.\n");
7313            }
7314        }
7315        "db" | "sqlite" | "sqlite3" => {
7316            out.push_str("SQLite Database detected.\n");
7317            out.push_str("Use `query_data` to execute SQL against this database.\n");
7318        }
7319        _ => {
7320            out.push_str("Unsupported format for deep audit. Showing first 10 lines:\n\n");
7321            let content = std::fs::read_to_string(&path)
7322                .map_err(|e| format!("Failed to read file: {}", e))?;
7323            for line in content.lines().take(10) {
7324                let _ = writeln!(out, "  {}", line);
7325            }
7326        }
7327    }
7328
7329    Ok(out)
7330}
7331
7332fn inspect_connectivity() -> Result<String, String> {
7333    let mut out = String::from("Host inspection: connectivity\n\n");
7334
7335    #[cfg(target_os = "windows")]
7336    {
7337        let inet_script = r#"
7338try {
7339    $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
7340    if ($r) { "REACHABLE" } else { "UNREACHABLE" }
7341} catch { "ERROR:" + $_.Exception.Message }
7342"#;
7343        if let Ok(o) = Command::new("powershell")
7344            .args(["-NoProfile", "-Command", inet_script])
7345            .output()
7346        {
7347            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7348            match text.as_str() {
7349                "REACHABLE" => out.push_str("Internet: reachable\n"),
7350                "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
7351                _ => {
7352                    let _ = writeln!(
7353                        out,
7354                        "Internet: {}",
7355                        text.trim_start_matches("ERROR:").trim()
7356                    );
7357                }
7358            }
7359        }
7360
7361        let dns_script = r#"
7362try {
7363    Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
7364    "DNS:ok"
7365} catch { "DNS:fail:" + $_.Exception.Message }
7366"#;
7367        if let Ok(o) = Command::new("powershell")
7368            .args(["-NoProfile", "-Command", dns_script])
7369            .output()
7370        {
7371            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7372            if text == "DNS:ok" {
7373                out.push_str("DNS: resolving correctly\n");
7374            } else {
7375                let detail = text.trim_start_matches("DNS:fail:").trim();
7376                let _ = writeln!(out, "DNS: failed — {}", detail);
7377            }
7378        }
7379
7380        let gw_script = r#"
7381(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
7382"#;
7383        if let Ok(o) = Command::new("powershell")
7384            .args(["-NoProfile", "-Command", gw_script])
7385            .output()
7386        {
7387            let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
7388            if !gw.is_empty() && gw != "0.0.0.0" {
7389                let _ = writeln!(out, "Default gateway: {}", gw);
7390            }
7391        }
7392    }
7393
7394    #[cfg(not(target_os = "windows"))]
7395    {
7396        let reachable = Command::new("ping")
7397            .args(["-c", "1", "-W", "2", "8.8.8.8"])
7398            .output()
7399            .map(|o| o.status.success())
7400            .unwrap_or(false);
7401        out.push_str(if reachable {
7402            "Internet: reachable\n"
7403        } else {
7404            "Internet: unreachable\n"
7405        });
7406        let dns_ok = Command::new("getent")
7407            .args(["hosts", "dns.google"])
7408            .output()
7409            .map(|o| o.status.success())
7410            .unwrap_or(false);
7411        out.push_str(if dns_ok {
7412            "DNS: resolving correctly\n"
7413        } else {
7414            "DNS: failed\n"
7415        });
7416        if let Ok(o) = Command::new("ip")
7417            .args(["route", "show", "default"])
7418            .output()
7419        {
7420            let text = String::from_utf8_lossy(&o.stdout);
7421            if let Some(line) = text.lines().next() {
7422                let _ = write!(out, "Default gateway: {}\n", line.trim());
7423            }
7424        }
7425    }
7426
7427    Ok(out.trim_end().to_string())
7428}
7429
7430// ── wifi ──────────────────────────────────────────────────────────────────────
7431
7432fn inspect_wifi() -> Result<String, String> {
7433    let mut out = String::from("Host inspection: wifi\n\n");
7434
7435    #[cfg(target_os = "windows")]
7436    {
7437        let output = Command::new("netsh")
7438            .args(["wlan", "show", "interfaces"])
7439            .output()
7440            .map_err(|e| format!("wifi: {e}"))?;
7441        let text = String::from_utf8_lossy(&output.stdout).into_owned();
7442
7443        if text.contains("There is no wireless interface") || text.trim().is_empty() {
7444            out.push_str("No wireless interface detected on this machine.\n");
7445            return Ok(out.trim_end().to_string());
7446        }
7447
7448        let fields = [
7449            ("SSID", "SSID"),
7450            ("State", "State"),
7451            ("Signal", "Signal"),
7452            ("Radio type", "Radio type"),
7453            ("Channel", "Channel"),
7454            ("Receive rate (Mbps)", "Download speed (Mbps)"),
7455            ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
7456            ("Authentication", "Authentication"),
7457            ("Network type", "Network type"),
7458        ];
7459
7460        let mut any = false;
7461        for line in text.lines() {
7462            let trimmed = line.trim();
7463            for (key, label) in &fields {
7464                if trimmed.starts_with(key) && trimmed.contains(':') {
7465                    let val = trimmed.split_once(':').map(|x| x.1).unwrap_or("").trim();
7466                    if !val.is_empty() {
7467                        let _ = writeln!(out, "  {label}: {val}");
7468                        any = true;
7469                    }
7470                }
7471            }
7472        }
7473        if !any {
7474            out.push_str("  (Wi-Fi adapter disconnected or no active connection)\n");
7475        }
7476    }
7477
7478    #[cfg(not(target_os = "windows"))]
7479    {
7480        if let Ok(o) = Command::new("nmcli")
7481            .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
7482            .output()
7483        {
7484            let text = String::from_utf8_lossy(&o.stdout).into_owned();
7485            let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
7486            if lines.is_empty() {
7487                out.push_str("No Wi-Fi devices found.\n");
7488            } else {
7489                for l in lines {
7490                    let _ = write!(out, "  {l}\n");
7491                }
7492            }
7493        } else if let Ok(o) = Command::new("iwconfig").output() {
7494            let text = String::from_utf8_lossy(&o.stdout).into_owned();
7495            if !text.trim().is_empty() {
7496                out.push_str(text.trim());
7497                out.push('\n');
7498            }
7499        } else {
7500            out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
7501        }
7502    }
7503
7504    Ok(out.trim_end().to_string())
7505}
7506
7507// ── connections ───────────────────────────────────────────────────────────────
7508
7509fn inspect_connections(max_entries: usize) -> Result<String, String> {
7510    let mut out = String::from("Host inspection: connections\n\n");
7511    let n = max_entries.clamp(1, 25);
7512
7513    #[cfg(target_os = "windows")]
7514    {
7515        let script = format!(
7516            r#"
7517try {{
7518    $procs = @{{}}
7519    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
7520    $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
7521        Sort-Object OwningProcess
7522    "TOTAL:" + $all.Count
7523    $all | Select-Object -First {n} | ForEach-Object {{
7524        $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
7525        $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
7526    }}
7527}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7528        );
7529
7530        let output = Command::new("powershell")
7531            .args(["-NoProfile", "-Command", &script])
7532            .output()
7533            .map_err(|e| format!("connections: {e}"))?;
7534
7535        let raw = String::from_utf8_lossy(&output.stdout);
7536        let text = raw.trim();
7537
7538        if text.starts_with("ERROR:") {
7539            let _ = writeln!(out, "Unable to query connections: {text}");
7540        } else {
7541            let mut total = 0usize;
7542            let mut rows = Vec::new();
7543            for line in text.lines() {
7544                if let Some(rest) = line.strip_prefix("TOTAL:") {
7545                    total = rest.trim().parse().unwrap_or(0);
7546                } else {
7547                    rows.push(line);
7548                }
7549            }
7550            let _ = write!(out, "Established TCP connections: {total}\n\n");
7551            for row in &rows {
7552                let mut it = row.splitn(4, '|');
7553                if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
7554                    (it.next(), it.next(), it.next(), it.next())
7555                {
7556                    let _ = writeln!(out, "  {:<15} (pid {:<5}) | {} → {}", p0, p1, p2, p3);
7557                }
7558            }
7559            if total > n {
7560                let _ = write!(
7561                    out,
7562                    "\n  ... {} more connections not shown\n",
7563                    total.saturating_sub(n)
7564                );
7565            }
7566        }
7567    }
7568
7569    #[cfg(not(target_os = "windows"))]
7570    {
7571        if let Ok(o) = Command::new("ss")
7572            .args(["-tnp", "state", "established"])
7573            .output()
7574        {
7575            let text = String::from_utf8_lossy(&o.stdout);
7576            let lines: Vec<&str> = text
7577                .lines()
7578                .skip(1)
7579                .filter(|l| !l.trim().is_empty())
7580                .collect();
7581            let _ = write!(out, "Established TCP connections: {}\n\n", lines.len());
7582            for line in lines.iter().take(n) {
7583                let _ = write!(out, "  {}\n", line.trim());
7584            }
7585            if lines.len() > n {
7586                let _ = write!(out, "\n  ... {} more not shown\n", lines.len() - n);
7587            }
7588        } else {
7589            out.push_str("ss not available — install iproute2\n");
7590        }
7591    }
7592
7593    Ok(out.trim_end().to_string())
7594}
7595
7596// ── vpn ───────────────────────────────────────────────────────────────────────
7597
7598fn inspect_vpn() -> Result<String, String> {
7599    let mut out = String::from("Host inspection: vpn\n\n");
7600
7601    #[cfg(target_os = "windows")]
7602    {
7603        let script = r#"
7604try {
7605    $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
7606        $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
7607        $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
7608    }
7609    if ($vpn) {
7610        foreach ($a in $vpn) {
7611            $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
7612        }
7613    } else { "NONE" }
7614} catch { "ERROR:" + $_.Exception.Message }
7615"#;
7616        let output = Command::new("powershell")
7617            .args(["-NoProfile", "-Command", script])
7618            .output()
7619            .map_err(|e| format!("vpn: {e}"))?;
7620
7621        let raw = String::from_utf8_lossy(&output.stdout);
7622        let text = raw.trim();
7623
7624        if text == "NONE" {
7625            out.push_str("No VPN adapters detected — no active VPN connection found.\n");
7626        } else if text.starts_with("ERROR:") {
7627            let _ = writeln!(out, "Unable to query adapters: {text}");
7628        } else {
7629            out.push_str("VPN adapters:\n\n");
7630            for line in text.lines() {
7631                let mut it = line.splitn(4, '|');
7632                if let (Some(name), Some(desc), Some(status)) = (it.next(), it.next(), it.next()) {
7633                    let media = it.next().unwrap_or("unknown");
7634                    let label = if status.trim() == "Up" {
7635                        "CONNECTED"
7636                    } else {
7637                        "disconnected"
7638                    };
7639                    let _ =
7640                        write!(out,
7641                        "  {name} [{label}]\n    {desc}\n    Status: {status} | Media: {media}\n\n"
7642                    );
7643                }
7644            }
7645        }
7646
7647        // Windows built-in VPN connections
7648        let ras_script = r#"
7649try {
7650    $c = Get-VpnConnection -ErrorAction Stop
7651    if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
7652    else { "NO_RAS" }
7653} catch { "NO_RAS" }
7654"#;
7655        if let Ok(o) = Command::new("powershell")
7656            .args(["-NoProfile", "-Command", ras_script])
7657            .output()
7658        {
7659            let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
7660            if t != "NO_RAS" && !t.is_empty() {
7661                out.push_str("Windows VPN connections:\n");
7662                for line in t.lines() {
7663                    let mut it = line.splitn(3, '|');
7664                    if let (Some(name), Some(status)) = (it.next(), it.next()) {
7665                        let server = it.next().unwrap_or("");
7666                        let _ = writeln!(out, "  {name} → {server} [{status}]");
7667                    }
7668                }
7669            }
7670        }
7671    }
7672
7673    #[cfg(not(target_os = "windows"))]
7674    {
7675        if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
7676            let text = String::from_utf8_lossy(&o.stdout);
7677            let vpn_ifaces: Vec<&str> = text
7678                .lines()
7679                .filter(|l| {
7680                    l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
7681                })
7682                .collect();
7683            if vpn_ifaces.is_empty() {
7684                out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
7685            } else {
7686                let _ = write!(out, "VPN-like interfaces ({}):\n", vpn_ifaces.len());
7687                for l in vpn_ifaces {
7688                    let _ = write!(out, "  {}\n", l.trim());
7689                }
7690            }
7691        }
7692    }
7693
7694    Ok(out.trim_end().to_string())
7695}
7696
7697// ── proxy ─────────────────────────────────────────────────────────────────────
7698
7699fn inspect_proxy() -> Result<String, String> {
7700    let mut out = String::from("Host inspection: proxy\n\n");
7701
7702    #[cfg(target_os = "windows")]
7703    {
7704        let script = r#"
7705$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
7706if ($ie) {
7707    "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
7708} else { "NONE" }
7709"#;
7710        if let Ok(o) = Command::new("powershell")
7711            .args(["-NoProfile", "-Command", script])
7712            .output()
7713        {
7714            let raw = String::from_utf8_lossy(&o.stdout);
7715            let text = raw.trim();
7716            if text != "NONE" && !text.is_empty() {
7717                let get = |key: &str| -> &str {
7718                    text.split('|')
7719                        .find(|s| s.starts_with(key))
7720                        .and_then(|s| s.split_once(':').map(|x| x.1))
7721                        .unwrap_or("")
7722                };
7723                let enabled = get("ENABLE");
7724                let server = get("SERVER");
7725                let overrides = get("OVERRIDE");
7726                out.push_str("WinINET / IE proxy:\n");
7727                let _ = writeln!(
7728                    out,
7729                    "  Enabled: {}",
7730                    if enabled == "1" { "yes" } else { "no" }
7731                );
7732                if !server.is_empty() && server != "None" {
7733                    let _ = writeln!(out, "  Proxy server: {server}");
7734                }
7735                if !overrides.is_empty() && overrides != "None" {
7736                    let _ = writeln!(out, "  Bypass list: {overrides}");
7737                }
7738                out.push('\n');
7739            }
7740        }
7741
7742        if let Ok(o) = Command::new("netsh")
7743            .args(["winhttp", "show", "proxy"])
7744            .output()
7745        {
7746            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7747            out.push_str("WinHTTP proxy:\n");
7748            for line in text.lines() {
7749                let l = line.trim();
7750                if !l.is_empty() {
7751                    let _ = writeln!(out, "  {l}");
7752                }
7753            }
7754            out.push('\n');
7755        }
7756
7757        let mut env_found = false;
7758        for var in &[
7759            "http_proxy",
7760            "https_proxy",
7761            "HTTP_PROXY",
7762            "HTTPS_PROXY",
7763            "no_proxy",
7764            "NO_PROXY",
7765        ] {
7766            if let Ok(val) = std::env::var(var) {
7767                if !env_found {
7768                    out.push_str("Environment proxy variables:\n");
7769                    env_found = true;
7770                }
7771                let _ = writeln!(out, "  {var}: {val}");
7772            }
7773        }
7774        if !env_found {
7775            out.push_str("No proxy environment variables set.\n");
7776        }
7777    }
7778
7779    #[cfg(not(target_os = "windows"))]
7780    {
7781        let mut found = false;
7782        for var in &[
7783            "http_proxy",
7784            "https_proxy",
7785            "HTTP_PROXY",
7786            "HTTPS_PROXY",
7787            "no_proxy",
7788            "NO_PROXY",
7789            "ALL_PROXY",
7790            "all_proxy",
7791        ] {
7792            if let Ok(val) = std::env::var(var) {
7793                if !found {
7794                    out.push_str("Proxy environment variables:\n");
7795                    found = true;
7796                }
7797                let _ = write!(out, "  {var}: {val}\n");
7798            }
7799        }
7800        if !found {
7801            out.push_str("No proxy environment variables set.\n");
7802        }
7803        if let Ok(content) = std::fs::read_to_string("/etc/environment") {
7804            let proxy_lines: Vec<&str> = content
7805                .lines()
7806                .filter(|l| l.to_lowercase().contains("proxy"))
7807                .collect();
7808            if !proxy_lines.is_empty() {
7809                out.push_str("\nSystem proxy (/etc/environment):\n");
7810                for l in proxy_lines {
7811                    let _ = write!(out, "  {l}\n");
7812                }
7813            }
7814        }
7815    }
7816
7817    Ok(out.trim_end().to_string())
7818}
7819
7820// ── firewall_rules ────────────────────────────────────────────────────────────
7821
7822fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
7823    let mut out = String::from("Host inspection: firewall_rules\n\n");
7824    let n = max_entries.clamp(1, 20);
7825
7826    #[cfg(target_os = "windows")]
7827    {
7828        let script = format!(
7829            r#"
7830try {{
7831    $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
7832        Where-Object {{
7833            $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
7834            $_.Owner -eq $null
7835        }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
7836    "TOTAL:" + $rules.Count
7837    $rules | ForEach-Object {{
7838        $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
7839        $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
7840        $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
7841    }}
7842}} catch {{ "ERROR:" + $_.Exception.Message }}"#
7843        );
7844
7845        let output = Command::new("powershell")
7846            .args(["-NoProfile", "-Command", &script])
7847            .output()
7848            .map_err(|e| format!("firewall_rules: {e}"))?;
7849
7850        let raw = String::from_utf8_lossy(&output.stdout);
7851        let text = raw.trim();
7852
7853        if text.starts_with("ERROR:") {
7854            let _ = writeln!(
7855                out,
7856                "Unable to query firewall rules: {}",
7857                text.trim_start_matches("ERROR:").trim()
7858            );
7859            out.push_str("This query may require running as administrator.\n");
7860        } else if text.is_empty() {
7861            out.push_str("No non-default enabled firewall rules found.\n");
7862        } else {
7863            let mut total = 0usize;
7864            for line in text.lines() {
7865                if let Some(rest) = line.strip_prefix("TOTAL:") {
7866                    total = rest.trim().parse().unwrap_or(0);
7867                    let _ = write!(out, "Non-default enabled rules (showing up to {n}):\n\n");
7868                } else {
7869                    let mut it = line.splitn(4, '|');
7870                    if let (Some(name), Some(dir), Some(action)) = (it.next(), it.next(), it.next())
7871                    {
7872                        let profile = it.next().unwrap_or("Any");
7873                        let icon = if action == "Block" { "[!]" } else { "   " };
7874                        let _ = writeln!(
7875                            out,
7876                            "  {icon} [{dir}] {action}: {name} (profile: {profile})"
7877                        );
7878                    }
7879                }
7880            }
7881            if total == 0 {
7882                out.push_str("No non-default enabled rules found.\n");
7883            }
7884        }
7885    }
7886
7887    #[cfg(not(target_os = "windows"))]
7888    {
7889        if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
7890            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7891            if !text.is_empty() {
7892                out.push_str(&text);
7893                out.push('\n');
7894            }
7895        } else if let Ok(o) = Command::new("iptables")
7896            .args(["-L", "-n", "--line-numbers"])
7897            .output()
7898        {
7899            let text = String::from_utf8_lossy(&o.stdout);
7900            for l in text.lines().take(n * 2) {
7901                let _ = write!(out, "  {l}\n");
7902            }
7903        } else {
7904            out.push_str("ufw and iptables not available or insufficient permissions.\n");
7905        }
7906    }
7907
7908    Ok(out.trim_end().to_string())
7909}
7910
7911// ── traceroute ────────────────────────────────────────────────────────────────
7912
7913fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
7914    let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
7915    let hops = max_entries.clamp(5, 30);
7916
7917    #[cfg(target_os = "windows")]
7918    {
7919        let output = Command::new("tracert")
7920            .args(["-d", "-h", &hops.to_string(), host])
7921            .output()
7922            .map_err(|e| format!("tracert: {e}"))?;
7923        let raw = String::from_utf8_lossy(&output.stdout);
7924        let mut hop_count = 0usize;
7925        for line in raw.lines() {
7926            let trimmed = line.trim();
7927            if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
7928                hop_count += 1;
7929                let _ = writeln!(out, "  {trimmed}");
7930            } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
7931                let _ = writeln!(out, "{trimmed}");
7932            }
7933        }
7934        if hop_count == 0 {
7935            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7936        }
7937    }
7938
7939    #[cfg(not(target_os = "windows"))]
7940    {
7941        let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
7942            || std::path::Path::new("/usr/sbin/traceroute").exists()
7943        {
7944            "traceroute"
7945        } else {
7946            "tracepath"
7947        };
7948        let output = Command::new(cmd)
7949            .args(["-m", &hops.to_string(), "-n", host])
7950            .output()
7951            .map_err(|e| format!("{cmd}: {e}"))?;
7952        let raw = String::from_utf8_lossy(&output.stdout);
7953        let mut hop_count = 0usize;
7954        for line in raw.lines().take(hops + 2) {
7955            let trimmed = line.trim();
7956            if !trimmed.is_empty() {
7957                hop_count += 1;
7958                let _ = write!(out, "  {trimmed}\n");
7959            }
7960        }
7961        if hop_count == 0 {
7962            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
7963        }
7964    }
7965
7966    Ok(out.trim_end().to_string())
7967}
7968
7969// ── dns_cache ─────────────────────────────────────────────────────────────────
7970
7971fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
7972    let mut out = String::from("Host inspection: dns_cache\n\n");
7973    let n = max_entries.clamp(10, 100);
7974
7975    #[cfg(target_os = "windows")]
7976    {
7977        let output = Command::new("powershell")
7978            .args([
7979                "-NoProfile",
7980                "-Command",
7981                "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
7982            ])
7983            .output()
7984            .map_err(|e| format!("dns_cache: {e}"))?;
7985
7986        let raw = String::from_utf8_lossy(&output.stdout);
7987        let lines: Vec<&str> = raw.lines().skip(1).collect();
7988        let total = lines.len();
7989
7990        if total == 0 {
7991            out.push_str("DNS cache is empty or could not be read.\n");
7992        } else {
7993            let _ = write!(out, "DNS cache entries (showing up to {n} of {total}):\n\n");
7994            let mut shown = 0usize;
7995            for line in lines.iter().take(n) {
7996                let mut it = line.splitn(4, ',');
7997                if let (Some(e), Some(rt), Some(d)) = (it.next(), it.next(), it.next()) {
7998                    let entry = e.trim_matches('"');
7999                    let rtype = rt.trim_matches('"');
8000                    let data = d.trim_matches('"');
8001                    let ttl = it.next().map(|s| s.trim_matches('"')).unwrap_or("?");
8002                    let _ = writeln!(out, "  {entry:<45} {rtype:<6} {data}  (TTL {ttl}s)");
8003                    shown += 1;
8004                }
8005            }
8006            if total > shown {
8007                let _ = write!(out, "\n  ... and {} more entries\n", total - shown);
8008            }
8009        }
8010    }
8011
8012    #[cfg(not(target_os = "windows"))]
8013    {
8014        if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
8015            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8016            if !text.is_empty() {
8017                out.push_str("systemd-resolved statistics:\n");
8018                for line in text.lines().take(n) {
8019                    let _ = write!(out, "  {line}\n");
8020                }
8021                out.push('\n');
8022            }
8023        }
8024        if let Ok(o) = Command::new("dscacheutil")
8025            .args(["-cachedump", "-entries", "Host"])
8026            .output()
8027        {
8028            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8029            if !text.is_empty() {
8030                out.push_str("DNS cache (macOS dscacheutil):\n");
8031                for line in text.lines().take(n) {
8032                    let _ = write!(out, "  {line}\n");
8033                }
8034            } else {
8035                out.push_str("DNS cache is empty or not accessible on this platform.\n");
8036            }
8037        } else {
8038            out.push_str(
8039                "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
8040            );
8041        }
8042    }
8043
8044    Ok(out.trim_end().to_string())
8045}
8046
8047// ── arp ───────────────────────────────────────────────────────────────────────
8048
8049fn inspect_arp() -> Result<String, String> {
8050    let mut out = String::from("Host inspection: arp\n\n");
8051
8052    #[cfg(target_os = "windows")]
8053    {
8054        let output = Command::new("arp")
8055            .args(["-a"])
8056            .output()
8057            .map_err(|e| format!("arp: {e}"))?;
8058        let raw = String::from_utf8_lossy(&output.stdout);
8059        let mut count = 0usize;
8060        for line in raw.lines() {
8061            let t = line.trim();
8062            if t.is_empty() {
8063                continue;
8064            }
8065            let _ = writeln!(out, "  {t}");
8066            if t.contains("dynamic") || t.contains("static") {
8067                count += 1;
8068            }
8069        }
8070        let _ = write!(out, "\nTotal entries: {count}\n");
8071    }
8072
8073    #[cfg(not(target_os = "windows"))]
8074    {
8075        if let Ok(o) = Command::new("arp").args(["-n"]).output() {
8076            let raw = String::from_utf8_lossy(&o.stdout);
8077            let mut count = 0usize;
8078            for line in raw.lines() {
8079                let t = line.trim();
8080                if !t.is_empty() {
8081                    let _ = write!(out, "  {t}\n");
8082                    count += 1;
8083                }
8084            }
8085            let _ = write!(out, "\nTotal entries: {}\n", count.saturating_sub(1));
8086        } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
8087            let raw = String::from_utf8_lossy(&o.stdout);
8088            let mut count = 0usize;
8089            for line in raw.lines() {
8090                let t = line.trim();
8091                if !t.is_empty() {
8092                    let _ = write!(out, "  {t}\n");
8093                    count += 1;
8094                }
8095            }
8096            let _ = write!(out, "\nTotal entries: {count}\n");
8097        } else {
8098            out.push_str("arp and ip neigh not available.\n");
8099        }
8100    }
8101
8102    Ok(out.trim_end().to_string())
8103}
8104
8105// ── route_table ───────────────────────────────────────────────────────────────
8106
8107fn inspect_route_table(max_entries: usize) -> Result<String, String> {
8108    let mut out = String::from("Host inspection: route_table\n\n");
8109    let n = max_entries.clamp(10, 50);
8110
8111    #[cfg(target_os = "windows")]
8112    {
8113        let script = r#"
8114try {
8115    $routes = Get-NetRoute -ErrorAction Stop |
8116        Where-Object { $_.RouteMetric -lt 9000 } |
8117        Sort-Object RouteMetric |
8118        Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
8119    "TOTAL:" + $routes.Count
8120    $routes | ForEach-Object {
8121        $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
8122    }
8123} catch { "ERROR:" + $_.Exception.Message }
8124"#;
8125        let output = Command::new("powershell")
8126            .args(["-NoProfile", "-Command", script])
8127            .output()
8128            .map_err(|e| format!("route_table: {e}"))?;
8129        let raw = String::from_utf8_lossy(&output.stdout);
8130        let text = raw.trim();
8131
8132        if text.starts_with("ERROR:") {
8133            let _ = writeln!(
8134                out,
8135                "Unable to read route table: {}",
8136                text.trim_start_matches("ERROR:").trim()
8137            );
8138        } else {
8139            let mut shown = 0usize;
8140            for line in text.lines() {
8141                if let Some(rest) = line.strip_prefix("TOTAL:") {
8142                    let total: usize = rest.trim().parse().unwrap_or(0);
8143                    let _ = write!(
8144                        out,
8145                        "Routing table (showing up to {n} of {total} routes):\n\n"
8146                    );
8147                    let _ = writeln!(
8148                        out,
8149                        "  {:<22} {:<18} {:>8}  Interface",
8150                        "Destination", "Next Hop", "Metric"
8151                    );
8152                    let _ = writeln!(out, "  {}", "-".repeat(70));
8153                } else if shown < n {
8154                    let mut it = line.splitn(4, '|');
8155                    if let (Some(dest), Some(p1), Some(metric), Some(iface)) =
8156                        (it.next(), it.next(), it.next(), it.next())
8157                    {
8158                        let hop = if p1.is_empty() || p1 == "0.0.0.0" || p1 == "::" {
8159                            "on-link"
8160                        } else {
8161                            p1
8162                        };
8163                        let _ = writeln!(out, "  {dest:<22} {hop:<18} {metric:>8}  {iface}");
8164                        shown += 1;
8165                    }
8166                }
8167            }
8168        }
8169    }
8170
8171    #[cfg(not(target_os = "windows"))]
8172    {
8173        if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
8174            let raw = String::from_utf8_lossy(&o.stdout);
8175            let lines: Vec<&str> = raw.lines().collect();
8176            let total = lines.len();
8177            let _ = write!(
8178                out,
8179                "Routing table (showing up to {n} of {total} routes):\n\n"
8180            );
8181            for line in lines.iter().take(n) {
8182                let _ = write!(out, "  {line}\n");
8183            }
8184            if total > n {
8185                let _ = write!(out, "\n  ... and {} more routes\n", total - n);
8186            }
8187        } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
8188            let raw = String::from_utf8_lossy(&o.stdout);
8189            for line in raw.lines().take(n) {
8190                let _ = write!(out, "  {line}\n");
8191            }
8192        } else {
8193            out.push_str("ip route and netstat not available.\n");
8194        }
8195    }
8196
8197    Ok(out.trim_end().to_string())
8198}
8199
8200// ── env ───────────────────────────────────────────────────────────────────────
8201
8202fn inspect_env(max_entries: usize) -> Result<String, String> {
8203    let mut out = String::from("Host inspection: env\n\n");
8204    let n = max_entries.clamp(10, 50);
8205
8206    fn looks_like_secret(name: &str) -> bool {
8207        let n = name.to_uppercase();
8208        n.contains("KEY")
8209            || n.contains("SECRET")
8210            || n.contains("TOKEN")
8211            || n.contains("PASSWORD")
8212            || n.contains("PASSWD")
8213            || n.contains("CREDENTIAL")
8214            || n.contains("AUTH")
8215            || n.contains("CERT")
8216            || n.contains("PRIVATE")
8217    }
8218
8219    let known_dev_vars: &[&str] = &[
8220        "CARGO_HOME",
8221        "RUSTUP_HOME",
8222        "GOPATH",
8223        "GOROOT",
8224        "GOBIN",
8225        "JAVA_HOME",
8226        "ANDROID_HOME",
8227        "ANDROID_SDK_ROOT",
8228        "PYTHONPATH",
8229        "PYTHONHOME",
8230        "VIRTUAL_ENV",
8231        "CONDA_DEFAULT_ENV",
8232        "CONDA_PREFIX",
8233        "NODE_PATH",
8234        "NVM_DIR",
8235        "NVM_BIN",
8236        "PNPM_HOME",
8237        "DENO_INSTALL",
8238        "DENO_DIR",
8239        "DOTNET_ROOT",
8240        "NUGET_PACKAGES",
8241        "CMAKE_HOME",
8242        "VCPKG_ROOT",
8243        "AWS_PROFILE",
8244        "AWS_REGION",
8245        "AWS_DEFAULT_REGION",
8246        "GCP_PROJECT",
8247        "GOOGLE_CLOUD_PROJECT",
8248        "GOOGLE_APPLICATION_CREDENTIALS",
8249        "AZURE_SUBSCRIPTION_ID",
8250        "DATABASE_URL",
8251        "REDIS_URL",
8252        "MONGO_URI",
8253        "EDITOR",
8254        "VISUAL",
8255        "SHELL",
8256        "TERM",
8257        "XDG_CONFIG_HOME",
8258        "XDG_DATA_HOME",
8259        "XDG_CACHE_HOME",
8260        "HOME",
8261        "USERPROFILE",
8262        "APPDATA",
8263        "LOCALAPPDATA",
8264        "TEMP",
8265        "TMP",
8266        "COMPUTERNAME",
8267        "USERNAME",
8268        "USERDOMAIN",
8269        "PROCESSOR_ARCHITECTURE",
8270        "NUMBER_OF_PROCESSORS",
8271        "OS",
8272        "HOMEDRIVE",
8273        "HOMEPATH",
8274        "HTTP_PROXY",
8275        "HTTPS_PROXY",
8276        "NO_PROXY",
8277        "ALL_PROXY",
8278        "http_proxy",
8279        "https_proxy",
8280        "no_proxy",
8281        "DOCKER_HOST",
8282        "DOCKER_BUILDKIT",
8283        "COMPOSE_PROJECT_NAME",
8284        "KUBECONFIG",
8285        "KUBE_CONTEXT",
8286        "CI",
8287        "GITHUB_ACTIONS",
8288        "GITLAB_CI",
8289        "LMSTUDIO_HOME",
8290        "HEMATITE_URL",
8291    ];
8292
8293    let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
8294    all_vars.sort_by(|a, b| a.0.cmp(&b.0));
8295    let total = all_vars.len();
8296
8297    let mut dev_found: Vec<String> = Vec::new();
8298    let mut secret_found: Vec<String> = Vec::new();
8299
8300    for (k, v) in &all_vars {
8301        if k == "PATH" {
8302            continue;
8303        }
8304        if looks_like_secret(k) {
8305            secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
8306        } else {
8307            let k_upper = k.to_uppercase();
8308            let is_known = known_dev_vars
8309                .iter()
8310                .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
8311            if is_known {
8312                let display = if v.len() > 120 {
8313                    format!("{k} = {}…", safe_head(v, 117))
8314                } else {
8315                    format!("{k} = {v}")
8316                };
8317                dev_found.push(display);
8318            }
8319        }
8320    }
8321
8322    let _ = write!(out, "Total environment variables: {total}\n\n");
8323
8324    if let Ok(p) = std::env::var("PATH") {
8325        let sep = if cfg!(target_os = "windows") {
8326            ';'
8327        } else {
8328            ':'
8329        };
8330        let count = p.split(sep).count();
8331        let _ = write!(
8332            out,
8333            "PATH: {count} entries (use topic=path for full audit)\n\n"
8334        );
8335    }
8336
8337    if !secret_found.is_empty() {
8338        let _ = writeln!(
8339            out,
8340            "=== Secret/credential variables ({} detected, values hidden) ===",
8341            secret_found.len()
8342        );
8343        for s in secret_found.iter().take(n) {
8344            let _ = writeln!(out, "  {s}");
8345        }
8346        out.push('\n');
8347    }
8348
8349    if !dev_found.is_empty() {
8350        let _ = writeln!(
8351            out,
8352            "=== Developer & tool variables ({}) ===",
8353            dev_found.len()
8354        );
8355        for d in dev_found.iter().take(n) {
8356            let _ = writeln!(out, "  {d}");
8357        }
8358        out.push('\n');
8359    }
8360
8361    let other_count = all_vars
8362        .iter()
8363        .filter(|(k, _)| {
8364            k != "PATH"
8365                && !looks_like_secret(k)
8366                && !known_dev_vars
8367                    .iter()
8368                    .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
8369        })
8370        .count();
8371    if other_count > 0 {
8372        let _ = writeln!(
8373            out,
8374            "Other variables: {other_count} (use 'env' in shell to see all)"
8375        );
8376    }
8377
8378    Ok(out.trim_end().to_string())
8379}
8380
8381// ── hosts_file ────────────────────────────────────────────────────────────────
8382
8383fn inspect_hosts_file() -> Result<String, String> {
8384    let mut out = String::from("Host inspection: hosts_file\n\n");
8385
8386    let hosts_path = if cfg!(target_os = "windows") {
8387        std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
8388    } else {
8389        std::path::PathBuf::from("/etc/hosts")
8390    };
8391
8392    let _ = write!(out, "Path: {}\n\n", hosts_path.display());
8393
8394    match fs::read_to_string(&hosts_path) {
8395        Ok(content) => {
8396            let mut active_entries: Vec<String> = Vec::new();
8397            let mut comment_lines = 0usize;
8398            let mut blank_lines = 0usize;
8399
8400            for line in content.lines() {
8401                let t = line.trim();
8402                if t.is_empty() {
8403                    blank_lines += 1;
8404                } else if t.starts_with('#') {
8405                    comment_lines += 1;
8406                } else {
8407                    active_entries.push(line.to_string());
8408                }
8409            }
8410
8411            let _ = write!(
8412                out,
8413                "Active entries: {}  |  Comment lines: {}  |  Blank lines: {}\n\n",
8414                active_entries.len(),
8415                comment_lines,
8416                blank_lines
8417            );
8418
8419            if active_entries.is_empty() {
8420                out.push_str(
8421                    "No active host entries (file contains only comments/blanks — standard default state).\n",
8422                );
8423            } else {
8424                out.push_str("=== Active entries ===\n");
8425                for entry in &active_entries {
8426                    let _ = writeln!(out, "  {entry}");
8427                }
8428                out.push('\n');
8429
8430                let custom: Vec<&String> = active_entries
8431                    .iter()
8432                    .filter(|e| {
8433                        let t = e.trim_start();
8434                        !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
8435                    })
8436                    .collect();
8437                if !custom.is_empty() {
8438                    let _ = writeln!(out, "[!] Custom (non-loopback) entries: {}", custom.len());
8439                    for e in &custom {
8440                        let _ = writeln!(out, "  {e}");
8441                    }
8442                } else {
8443                    out.push_str("All active entries are standard loopback or block entries.\n");
8444                }
8445            }
8446
8447            out.push_str("\n=== Full file ===\n");
8448            for line in content.lines() {
8449                let _ = writeln!(out, "  {line}");
8450            }
8451        }
8452        Err(e) => {
8453            let _ = writeln!(out, "Could not read hosts file: {e}");
8454            if cfg!(target_os = "windows") {
8455                out.push_str(
8456                    "On Windows, run Hematite as Administrator if permission is denied.\n",
8457                );
8458            }
8459        }
8460    }
8461
8462    Ok(out.trim_end().to_string())
8463}
8464
8465// ── docker ────────────────────────────────────────────────────────────────────
8466
8467struct AuditFinding {
8468    finding: String,
8469    impact: String,
8470    fix: String,
8471}
8472
8473#[cfg(target_os = "windows")]
8474#[derive(Debug, Clone)]
8475struct WindowsPnpDevice {
8476    name: String,
8477    status: String,
8478    problem: Option<u64>,
8479    class_name: Option<String>,
8480    instance_id: Option<String>,
8481}
8482
8483#[cfg(target_os = "windows")]
8484#[derive(Debug, Clone)]
8485struct WindowsSoundDevice {
8486    name: String,
8487    status: String,
8488    manufacturer: Option<String>,
8489}
8490
8491struct DockerMountAudit {
8492    mount_type: String,
8493    source: Option<String>,
8494    destination: String,
8495    name: Option<String>,
8496    read_write: Option<bool>,
8497    driver: Option<String>,
8498    exists_on_host: Option<bool>,
8499}
8500
8501struct DockerContainerAudit {
8502    name: String,
8503    image: String,
8504    status: String,
8505    mounts: Vec<DockerMountAudit>,
8506}
8507
8508struct DockerVolumeAudit {
8509    name: String,
8510    driver: String,
8511    mountpoint: Option<String>,
8512    scope: Option<String>,
8513}
8514
8515#[cfg(target_os = "windows")]
8516struct WslDistroAudit {
8517    name: String,
8518    state: String,
8519    version: String,
8520}
8521
8522#[cfg(target_os = "windows")]
8523struct WslRootUsage {
8524    total_kb: u64,
8525    used_kb: u64,
8526    avail_kb: u64,
8527    use_percent: String,
8528    mnt_c_present: Option<bool>,
8529}
8530
8531fn docker_engine_version() -> Result<String, String> {
8532    let version_output = Command::new("docker")
8533        .args(["version", "--format", "{{.Server.Version}}"])
8534        .output();
8535
8536    match version_output {
8537        Err(_) => Err(
8538            "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
8539        ),
8540        Ok(o) if !o.status.success() => {
8541            let stderr = String::from_utf8_lossy(&o.stderr);
8542            if stderr.contains("cannot connect")
8543                || stderr.contains("Is the docker daemon running")
8544                || stderr.contains("pipe")
8545                || stderr.contains("socket")
8546            {
8547                Err(
8548                    "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
8549                )
8550            } else {
8551                Err(format!("Docker: error - {}", stderr.trim()))
8552            }
8553        }
8554        Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
8555    }
8556}
8557
8558fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
8559    let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
8560        return Vec::new();
8561    };
8562    let Value::Array(entries) = value else {
8563        return Vec::new();
8564    };
8565
8566    let mut mounts = Vec::with_capacity(entries.len());
8567    for entry in entries {
8568        let mount_type = entry
8569            .get("Type")
8570            .and_then(|v| v.as_str())
8571            .unwrap_or("unknown")
8572            .to_string();
8573        let source = entry
8574            .get("Source")
8575            .and_then(|v| v.as_str())
8576            .map(|v| v.to_string());
8577        let destination = entry
8578            .get("Destination")
8579            .and_then(|v| v.as_str())
8580            .unwrap_or("?")
8581            .to_string();
8582        let name = entry
8583            .get("Name")
8584            .and_then(|v| v.as_str())
8585            .map(|v| v.to_string());
8586        let read_write = entry.get("RW").and_then(|v| v.as_bool());
8587        let driver = entry
8588            .get("Driver")
8589            .and_then(|v| v.as_str())
8590            .map(|v| v.to_string());
8591        let exists_on_host = if mount_type == "bind" {
8592            source.as_deref().map(|path| Path::new(path).exists())
8593        } else {
8594            None
8595        };
8596        mounts.push(DockerMountAudit {
8597            mount_type,
8598            source,
8599            destination,
8600            name,
8601            read_write,
8602            driver,
8603            exists_on_host,
8604        });
8605    }
8606
8607    mounts
8608}
8609
8610fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
8611    let mut audit = DockerVolumeAudit {
8612        name: name.to_string(),
8613        driver: "unknown".to_string(),
8614        mountpoint: None,
8615        scope: None,
8616    };
8617
8618    if let Ok(output) = Command::new("docker")
8619        .args(["volume", "inspect", name, "--format", "{{json .}}"])
8620        .output()
8621    {
8622        if output.status.success() {
8623            if let Ok(value) =
8624                serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
8625            {
8626                audit.driver = value
8627                    .get("Driver")
8628                    .and_then(|v| v.as_str())
8629                    .unwrap_or("unknown")
8630                    .to_string();
8631                audit.mountpoint = value
8632                    .get("Mountpoint")
8633                    .and_then(|v| v.as_str())
8634                    .map(|v| v.to_string());
8635                audit.scope = value
8636                    .get("Scope")
8637                    .and_then(|v| v.as_str())
8638                    .map(|v| v.to_string());
8639            }
8640        }
8641    }
8642
8643    audit
8644}
8645
8646#[cfg(target_os = "windows")]
8647fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
8648    let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
8649    for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
8650        let path = local_app_data
8651            .join("Docker")
8652            .join("wsl")
8653            .join("disk")
8654            .join(file_name);
8655        if let Ok(metadata) = fs::metadata(&path) {
8656            return Some((path, metadata.len()));
8657        }
8658    }
8659    None
8660}
8661
8662#[cfg(target_os = "windows")]
8663fn clean_wsl_text(raw: &[u8]) -> String {
8664    String::from_utf8_lossy(raw)
8665        .chars()
8666        .filter(|c| *c != '\0')
8667        .collect()
8668}
8669
8670#[cfg(target_os = "windows")]
8671fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
8672    let mut distros = Vec::new();
8673    for line in raw.lines() {
8674        let trimmed = line.trim();
8675        if trimmed.is_empty()
8676            || trimmed.to_uppercase().starts_with("NAME")
8677            || trimmed.starts_with("---")
8678        {
8679            continue;
8680        }
8681        let normalized = trimmed.trim_start_matches('*').trim();
8682        let cols: Vec<&str> = normalized.split_whitespace().collect();
8683        if cols.len() < 3 {
8684            continue;
8685        }
8686        let version = cols[cols.len() - 1].to_string();
8687        let state = cols[cols.len() - 2].to_string();
8688        let name = cols[..cols.len() - 2].join(" ");
8689        if !name.is_empty() {
8690            distros.push(WslDistroAudit {
8691                name,
8692                state,
8693                version,
8694            });
8695        }
8696    }
8697    distros
8698}
8699
8700#[cfg(target_os = "windows")]
8701fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
8702    let output = Command::new("wsl")
8703        .args([
8704            "-d",
8705            distro_name,
8706            "--",
8707            "sh",
8708            "-lc",
8709            "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
8710        ])
8711        .output()
8712        .ok()?;
8713    if !output.status.success() {
8714        return None;
8715    }
8716
8717    let text = clean_wsl_text(&output.stdout);
8718    let mut total_kb = 0;
8719    let mut used_kb = 0;
8720    let mut avail_kb = 0;
8721    let mut use_percent = String::from("unknown");
8722    let mut mnt_c_present = None;
8723
8724    for line in text.lines() {
8725        let trimmed = line.trim();
8726        if trimmed.starts_with("__MNTC__:") {
8727            mnt_c_present = Some(trimmed.ends_with("ok"));
8728            continue;
8729        }
8730        let mut it = trimmed.split_whitespace();
8731        if let (Some(_), Some(total), Some(used), Some(avail), Some(pct), Some(_)) = (
8732            it.next(),
8733            it.next(),
8734            it.next(),
8735            it.next(),
8736            it.next(),
8737            it.next(),
8738        ) {
8739            total_kb = total.parse::<u64>().unwrap_or(0);
8740            used_kb = used.parse::<u64>().unwrap_or(0);
8741            avail_kb = avail.parse::<u64>().unwrap_or(0);
8742            use_percent = pct.to_string();
8743        }
8744    }
8745
8746    Some(WslRootUsage {
8747        total_kb,
8748        used_kb,
8749        avail_kb,
8750        use_percent,
8751        mnt_c_present,
8752    })
8753}
8754
8755#[cfg(target_os = "windows")]
8756fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
8757    let mut vhds = Vec::new();
8758    let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
8759        return vhds;
8760    };
8761    let packages_dir = local_app_data.join("Packages");
8762    let Ok(entries) = fs::read_dir(packages_dir) else {
8763        return vhds;
8764    };
8765
8766    for entry in entries.flatten() {
8767        let path = entry.path().join("LocalState").join("ext4.vhdx");
8768        if let Ok(metadata) = fs::metadata(&path) {
8769            vhds.push((path, metadata.len()));
8770        }
8771    }
8772    vhds.sort_by_key(|b| std::cmp::Reverse(b.1));
8773    vhds
8774}
8775
8776fn inspect_docker(max_entries: usize) -> Result<String, String> {
8777    let mut out = String::from("Host inspection: docker\n\n");
8778    let n = max_entries.clamp(5, 25);
8779
8780    let version_output = Command::new("docker")
8781        .args(["version", "--format", "{{.Server.Version}}"])
8782        .output();
8783
8784    match version_output {
8785        Err(_) => {
8786            out.push_str("Docker: not found on PATH.\n");
8787            out.push_str(
8788                "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
8789            );
8790            return Ok(out.trim_end().to_string());
8791        }
8792        Ok(o) if !o.status.success() => {
8793            let stderr = String::from_utf8_lossy(&o.stderr);
8794            if stderr.contains("cannot connect")
8795                || stderr.contains("Is the docker daemon running")
8796                || stderr.contains("pipe")
8797                || stderr.contains("socket")
8798            {
8799                out.push_str("Docker: installed but daemon is NOT running.\n");
8800                out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
8801            } else {
8802                let _ = writeln!(out, "Docker: error — {}", stderr.trim());
8803            }
8804            return Ok(out.trim_end().to_string());
8805        }
8806        Ok(o) => {
8807            let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
8808            let _ = writeln!(out, "Docker Engine: {version}");
8809        }
8810    }
8811
8812    if let Ok(o) = Command::new("docker")
8813        .args([
8814            "info",
8815            "--format",
8816            "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
8817        ])
8818        .output()
8819    {
8820        let info = String::from_utf8_lossy(&o.stdout);
8821        for line in info.lines() {
8822            let t = line.trim();
8823            if !t.is_empty() {
8824                let _ = writeln!(out, "  {t}");
8825            }
8826        }
8827        out.push('\n');
8828    }
8829
8830    if let Ok(o) = Command::new("docker")
8831        .args([
8832            "ps",
8833            "--format",
8834            "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
8835        ])
8836        .output()
8837    {
8838        let raw = String::from_utf8_lossy(&o.stdout);
8839        let lines: Vec<&str> = raw.lines().collect();
8840        if lines.len() <= 1 {
8841            out.push_str("Running containers: none\n\n");
8842        } else {
8843            let _ = writeln!(
8844                out,
8845                "=== Running containers ({}) ===",
8846                lines.len().saturating_sub(1)
8847            );
8848            for line in lines.iter().take(n + 1) {
8849                let _ = writeln!(out, "  {line}");
8850            }
8851            if lines.len() > n + 1 {
8852                let _ = writeln!(out, "  ... and {} more", lines.len() - n - 1);
8853            }
8854            out.push('\n');
8855        }
8856    }
8857
8858    if let Ok(o) = Command::new("docker")
8859        .args([
8860            "images",
8861            "--format",
8862            "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
8863        ])
8864        .output()
8865    {
8866        let raw = String::from_utf8_lossy(&o.stdout);
8867        let lines: Vec<&str> = raw.lines().collect();
8868        if lines.len() > 1 {
8869            let _ = writeln!(
8870                out,
8871                "=== Local images ({}) ===",
8872                lines.len().saturating_sub(1)
8873            );
8874            for line in lines.iter().take(n + 1) {
8875                let _ = writeln!(out, "  {line}");
8876            }
8877            if lines.len() > n + 1 {
8878                let _ = writeln!(out, "  ... and {} more", lines.len() - n - 1);
8879            }
8880            out.push('\n');
8881        }
8882    }
8883
8884    if let Ok(o) = Command::new("docker")
8885        .args([
8886            "compose",
8887            "ls",
8888            "--format",
8889            "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
8890        ])
8891        .output()
8892    {
8893        let raw = String::from_utf8_lossy(&o.stdout);
8894        let lines: Vec<&str> = raw.lines().collect();
8895        if lines.len() > 1 {
8896            let _ = writeln!(
8897                out,
8898                "=== Compose projects ({}) ===",
8899                lines.len().saturating_sub(1)
8900            );
8901            for line in lines.iter().take(n + 1) {
8902                let _ = writeln!(out, "  {line}");
8903            }
8904            out.push('\n');
8905        }
8906    }
8907
8908    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8909        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8910        if !ctx.is_empty() {
8911            let _ = writeln!(out, "Active context: {ctx}");
8912        }
8913    }
8914
8915    Ok(out.trim_end().to_string())
8916}
8917
8918// ── wsl ───────────────────────────────────────────────────────────────────────
8919
8920fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
8921    let mut out = String::from("Host inspection: docker_filesystems\n\n");
8922    let n = max_entries.clamp(3, 12);
8923
8924    match docker_engine_version() {
8925        Ok(version) => {
8926            let _ = writeln!(out, "Docker Engine: {version}");
8927        }
8928        Err(message) => {
8929            out.push_str(&message);
8930            return Ok(out.trim_end().to_string());
8931        }
8932    }
8933
8934    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
8935        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
8936        if !ctx.is_empty() {
8937            let _ = writeln!(out, "Active context: {ctx}");
8938        }
8939    }
8940    out.push('\n');
8941
8942    let mut containers = Vec::with_capacity(n);
8943    if let Ok(o) = Command::new("docker")
8944        .args([
8945            "ps",
8946            "-a",
8947            "--format",
8948            "{{.Names}}\t{{.Image}}\t{{.Status}}",
8949        ])
8950        .output()
8951    {
8952        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8953            let mut it = line.splitn(3, '\t');
8954            let (Some(name_raw), Some(image_raw), Some(status_raw)) =
8955                (it.next(), it.next(), it.next())
8956            else {
8957                continue;
8958            };
8959            let name = name_raw.trim().to_string();
8960            if name.is_empty() {
8961                continue;
8962            }
8963            let inspect_output = Command::new("docker")
8964                .args(["inspect", &name, "--format", "{{json .Mounts}}"])
8965                .output();
8966            let mounts = match inspect_output {
8967                Ok(result) if result.status.success() => {
8968                    parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
8969                }
8970                _ => Vec::new(),
8971            };
8972            containers.push(DockerContainerAudit {
8973                name,
8974                image: image_raw.trim().to_string(),
8975                status: status_raw.trim().to_string(),
8976                mounts,
8977            });
8978        }
8979    }
8980
8981    let mut volumes = Vec::with_capacity(n);
8982    if let Ok(o) = Command::new("docker")
8983        .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
8984        .output()
8985    {
8986        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
8987            let mut it = line.split('\t');
8988            let Some(name) = it.next().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
8989                continue;
8990            };
8991            let driver_hint = it.next().map(|v| v.trim()).filter(|v| !v.is_empty());
8992            let mut audit = inspect_docker_volume(name);
8993            if audit.driver == "unknown" {
8994                audit.driver = driver_hint.unwrap_or("unknown").to_string();
8995            }
8996            volumes.push(audit);
8997        }
8998    }
8999
9000    let mut findings = Vec::with_capacity(4);
9001    for container in &containers {
9002        for mount in &container.mounts {
9003            if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
9004                let source = mount.source.as_deref().unwrap_or("<unknown>");
9005                findings.push(AuditFinding {
9006                    finding: format!(
9007                        "Container '{}' has a bind mount whose host source is missing: {} -> {}",
9008                        container.name, source, mount.destination
9009                    ),
9010                    impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
9011                    fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
9012                });
9013            }
9014        }
9015    }
9016
9017    #[cfg(target_os = "windows")]
9018    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
9019        if size_bytes >= 20 * 1024 * 1024 * 1024 {
9020            findings.push(AuditFinding {
9021                finding: format!(
9022                    "Docker Desktop disk image is large: {} at {}",
9023                    human_bytes(size_bytes),
9024                    path.display()
9025                ),
9026                impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
9027                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(),
9028            });
9029        }
9030    }
9031
9032    out.push_str("=== Findings ===\n");
9033    if findings.is_empty() {
9034        out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
9035        out.push_str("  Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
9036        out.push_str("  Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
9037    } else {
9038        for finding in &findings {
9039            let _ = writeln!(out, "- Finding: {}", finding.finding);
9040            let _ = writeln!(out, "  Impact: {}", finding.impact);
9041            let _ = writeln!(out, "  Fix: {}", finding.fix);
9042        }
9043    }
9044
9045    out.push_str("\n=== Container mount summary ===\n");
9046    if containers.is_empty() {
9047        out.push_str("- No containers found.\n");
9048    } else {
9049        for container in &containers {
9050            let _ = writeln!(
9051                out,
9052                "- {} ({}) [{}]",
9053                container.name, container.image, container.status
9054            );
9055            if container.mounts.is_empty() {
9056                out.push_str("  - no mounts reported\n");
9057                continue;
9058            }
9059            for mount in &container.mounts {
9060                let mut source = mount
9061                    .name
9062                    .clone()
9063                    .or_else(|| mount.source.clone())
9064                    .unwrap_or_else(|| "<unknown>".to_string());
9065                if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
9066                    source.push_str(" [missing]");
9067                }
9068                let mut extras = Vec::with_capacity(2);
9069                if let Some(rw) = mount.read_write {
9070                    extras.push(if rw { "rw" } else { "ro" }.to_string());
9071                }
9072                if let Some(driver) = &mount.driver {
9073                    extras.push(format!("driver={driver}"));
9074                }
9075                let extra_suffix = if extras.is_empty() {
9076                    String::new()
9077                } else {
9078                    format!(" ({})", extras.join(", "))
9079                };
9080                let _ = writeln!(
9081                    out,
9082                    "  - {}: {} -> {}{}",
9083                    mount.mount_type, source, mount.destination, extra_suffix
9084                );
9085            }
9086        }
9087    }
9088
9089    out.push_str("\n=== Named volumes ===\n");
9090    if volumes.is_empty() {
9091        out.push_str("- No named volumes found.\n");
9092    } else {
9093        for volume in &volumes {
9094            let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
9095            if let Some(scope) = &volume.scope {
9096                let _ = write!(detail, ", scope: {scope}");
9097            }
9098            if let Some(mountpoint) = &volume.mountpoint {
9099                let _ = write!(detail, ", mountpoint: {mountpoint}");
9100            }
9101            let _ = writeln!(out, "{detail}");
9102        }
9103    }
9104
9105    #[cfg(target_os = "windows")]
9106    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
9107        out.push_str("\n=== Docker Desktop disk ===\n");
9108        let _ = writeln!(out, "- {} at {}", human_bytes(size_bytes), path.display());
9109    }
9110
9111    Ok(out.trim_end().to_string())
9112}
9113
9114fn inspect_wsl() -> Result<String, String> {
9115    let mut out = String::from("Host inspection: wsl\n\n");
9116
9117    #[cfg(target_os = "windows")]
9118    {
9119        if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
9120            let raw = String::from_utf8_lossy(&o.stdout);
9121            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
9122            for line in cleaned.lines().take(4) {
9123                let t = line.trim();
9124                if !t.is_empty() {
9125                    let _ = writeln!(out, "  {t}");
9126                }
9127            }
9128            out.push('\n');
9129        }
9130
9131        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
9132        match list_output {
9133            Err(e) => {
9134                let _ = writeln!(out, "WSL: wsl.exe error: {e}");
9135                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
9136            }
9137            Ok(o) if !o.status.success() => {
9138                let stderr = String::from_utf8_lossy(&o.stderr);
9139                let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
9140                let _ = writeln!(out, "WSL: error — {}", cleaned.trim());
9141                out.push_str("Run: wsl --install\n");
9142            }
9143            Ok(o) => {
9144                let raw = String::from_utf8_lossy(&o.stdout);
9145                let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
9146                let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
9147                let distro_lines: Vec<&str> = lines
9148                    .iter()
9149                    .filter(|l| {
9150                        let t = l.trim();
9151                        !t.is_empty()
9152                            && !t.to_uppercase().starts_with("NAME")
9153                            && !t.starts_with("---")
9154                    })
9155                    .copied()
9156                    .collect();
9157
9158                if distro_lines.is_empty() {
9159                    out.push_str("WSL: installed but no distributions found.\n");
9160                    out.push_str("Install a distro: wsl --install -d Ubuntu\n");
9161                } else {
9162                    out.push_str("=== WSL Distributions ===\n");
9163                    for line in &lines {
9164                        let _ = writeln!(out, "  {}", line.trim());
9165                    }
9166                    let _ = write!(out, "\nTotal distributions: {}\n", distro_lines.len());
9167                }
9168            }
9169        }
9170
9171        if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
9172            let raw = String::from_utf8_lossy(&o.stdout);
9173            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
9174            let status_lines: Vec<&str> = cleaned
9175                .lines()
9176                .filter(|l| !l.trim().is_empty())
9177                .take(8)
9178                .collect();
9179            if !status_lines.is_empty() {
9180                out.push_str("\n=== WSL status ===\n");
9181                for line in status_lines {
9182                    let _ = writeln!(out, "  {}", line.trim());
9183                }
9184            }
9185        }
9186    }
9187
9188    #[cfg(not(target_os = "windows"))]
9189    {
9190        out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
9191        out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
9192    }
9193
9194    Ok(out.trim_end().to_string())
9195}
9196
9197// ── ssh ───────────────────────────────────────────────────────────────────────
9198
9199fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
9200    let mut out = String::from("Host inspection: wsl_filesystems\n\n");
9201
9202    #[cfg(target_os = "windows")]
9203    {
9204        let n = max_entries.clamp(3, 12);
9205        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
9206        let distros = match list_output {
9207            Err(e) => {
9208                let _ = writeln!(out, "WSL: wsl.exe error: {e}");
9209                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
9210                return Ok(out.trim_end().to_string());
9211            }
9212            Ok(o) if !o.status.success() => {
9213                let cleaned = clean_wsl_text(&o.stderr);
9214                let _ = writeln!(out, "WSL: error - {}", cleaned.trim());
9215                out.push_str("Run: wsl --install\n");
9216                return Ok(out.trim_end().to_string());
9217            }
9218            Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
9219        };
9220
9221        let _ = write!(out, "Distributions detected: {}\n\n", distros.len());
9222
9223        let vhdx_files = collect_wsl_vhdx_files();
9224        let mut findings = Vec::with_capacity(4);
9225        let mut live_usage = Vec::with_capacity(n);
9226
9227        for distro in distros.iter().take(n) {
9228            if distro.state.eq_ignore_ascii_case("Running") {
9229                if let Some(usage) = wsl_root_usage(&distro.name) {
9230                    if let Some(false) = usage.mnt_c_present {
9231                        findings.push(AuditFinding {
9232                            finding: format!(
9233                                "Distro '{}' is running without /mnt/c available",
9234                                distro.name
9235                            ),
9236                            impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
9237                            fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
9238                        });
9239                    }
9240
9241                    let percent_num = usage
9242                        .use_percent
9243                        .trim_end_matches('%')
9244                        .parse::<u32>()
9245                        .unwrap_or(0);
9246                    if percent_num >= 85 {
9247                        findings.push(AuditFinding {
9248                            finding: format!(
9249                                "Distro '{}' root filesystem is {} full",
9250                                distro.name, usage.use_percent
9251                            ),
9252                            impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
9253                            fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
9254                        });
9255                    }
9256                    live_usage.push((distro.name.clone(), usage));
9257                }
9258            }
9259        }
9260
9261        for (path, size_bytes) in vhdx_files.iter().take(n) {
9262            if *size_bytes >= 20 * 1024 * 1024 * 1024 {
9263                findings.push(AuditFinding {
9264                    finding: format!(
9265                        "Host-side WSL disk image is large: {} at {}",
9266                        human_bytes(*size_bytes),
9267                        path.display()
9268                    ),
9269                    impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
9270                    fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
9271                });
9272            }
9273        }
9274
9275        out.push_str("=== Findings ===\n");
9276        if findings.is_empty() {
9277            out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
9278            out.push_str("  Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
9279            out.push_str("  Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
9280        } else {
9281            for finding in &findings {
9282                let _ = writeln!(out, "- Finding: {}", finding.finding);
9283                let _ = writeln!(out, "  Impact: {}", finding.impact);
9284                let _ = writeln!(out, "  Fix: {}", finding.fix);
9285            }
9286        }
9287
9288        out.push_str("\n=== Distro bridge and root usage ===\n");
9289        if distros.is_empty() {
9290            out.push_str("- No WSL distributions found.\n");
9291        } else {
9292            for distro in distros.iter().take(n) {
9293                let _ = writeln!(
9294                    out,
9295                    "- {} [state: {}, version: {}]",
9296                    distro.name, distro.state, distro.version
9297                );
9298                if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
9299                    let _ = writeln!(
9300                        out,
9301                        "  - rootfs: {} used / {} total ({}), free: {}",
9302                        human_bytes(usage.used_kb * 1024),
9303                        human_bytes(usage.total_kb * 1024),
9304                        usage.use_percent,
9305                        human_bytes(usage.avail_kb * 1024)
9306                    );
9307                    match usage.mnt_c_present {
9308                        Some(true) => out.push_str("  - /mnt/c bridge: present\n"),
9309                        Some(false) => out.push_str("  - /mnt/c bridge: missing\n"),
9310                        None => out.push_str("  - /mnt/c bridge: unknown\n"),
9311                    }
9312                } else if distro.state.eq_ignore_ascii_case("Running") {
9313                    out.push_str("  - live rootfs check: unavailable\n");
9314                } else {
9315                    out.push_str(
9316                        "  - live rootfs check: skipped to avoid starting a stopped distro\n",
9317                    );
9318                }
9319            }
9320        }
9321
9322        out.push_str("\n=== Host-side VHDX files ===\n");
9323        if vhdx_files.is_empty() {
9324            out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
9325        } else {
9326            for (path, size_bytes) in vhdx_files.iter().take(n) {
9327                let _ = writeln!(out, "- {} at {}", human_bytes(*size_bytes), path.display());
9328            }
9329        }
9330    }
9331
9332    #[cfg(not(target_os = "windows"))]
9333    {
9334        let _ = max_entries;
9335        out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
9336        out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
9337    }
9338
9339    Ok(out.trim_end().to_string())
9340}
9341
9342fn dirs_home() -> Option<PathBuf> {
9343    std::env::var("HOME")
9344        .ok()
9345        .map(PathBuf::from)
9346        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
9347}
9348
9349fn inspect_ssh() -> Result<String, String> {
9350    let mut out = String::from("Host inspection: ssh\n\n");
9351
9352    if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
9353        let ver = if o.stdout.is_empty() {
9354            String::from_utf8_lossy(&o.stderr).trim().to_string()
9355        } else {
9356            String::from_utf8_lossy(&o.stdout).trim().to_string()
9357        };
9358        if !ver.is_empty() {
9359            let _ = writeln!(out, "SSH client: {ver}");
9360        }
9361    } else {
9362        out.push_str("SSH client: not found on PATH.\n");
9363    }
9364
9365    #[cfg(target_os = "windows")]
9366    {
9367        let script = r#"
9368$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
9369if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
9370else { "SSHD:not_installed" }
9371"#;
9372        if let Ok(o) = Command::new("powershell")
9373            .args(["-NoProfile", "-Command", script])
9374            .output()
9375        {
9376            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
9377            if text.contains("not_installed") {
9378                out.push_str("SSH server (sshd): not installed\n");
9379            } else {
9380                let _ = writeln!(
9381                    out,
9382                    "SSH server (sshd): {}",
9383                    text.trim_start_matches("SSHD:")
9384                );
9385            }
9386        }
9387    }
9388
9389    #[cfg(not(target_os = "windows"))]
9390    {
9391        if let Ok(o) = Command::new("systemctl")
9392            .args(["is-active", "sshd"])
9393            .output()
9394        {
9395            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
9396            let _ = write!(out, "SSH server (sshd): {status}\n");
9397        } else if let Ok(o) = Command::new("systemctl")
9398            .args(["is-active", "ssh"])
9399            .output()
9400        {
9401            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
9402            let _ = write!(out, "SSH server (ssh): {status}\n");
9403        }
9404    }
9405
9406    out.push('\n');
9407
9408    if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
9409        if ssh_dir.exists() {
9410            let _ = writeln!(out, "~/.ssh: {}", ssh_dir.display());
9411
9412            let kh = ssh_dir.join("known_hosts");
9413            if kh.exists() {
9414                let count = fs::read_to_string(&kh)
9415                    .map(|c| {
9416                        c.lines()
9417                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
9418                            .count()
9419                    })
9420                    .unwrap_or(0);
9421                let _ = writeln!(out, "  known_hosts: {count} entries");
9422            } else {
9423                out.push_str("  known_hosts: not present\n");
9424            }
9425
9426            let ak = ssh_dir.join("authorized_keys");
9427            if ak.exists() {
9428                let count = fs::read_to_string(&ak)
9429                    .map(|c| {
9430                        c.lines()
9431                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
9432                            .count()
9433                    })
9434                    .unwrap_or(0);
9435                let _ = writeln!(out, "  authorized_keys: {count} public keys");
9436            } else {
9437                out.push_str("  authorized_keys: not present\n");
9438            }
9439
9440            let key_names = [
9441                "id_rsa",
9442                "id_ed25519",
9443                "id_ecdsa",
9444                "id_dsa",
9445                "id_ecdsa_sk",
9446                "id_ed25519_sk",
9447            ];
9448            let found_keys: Vec<&str> = key_names
9449                .iter()
9450                .filter(|k| ssh_dir.join(k).exists())
9451                .copied()
9452                .collect();
9453            if !found_keys.is_empty() {
9454                let _ = writeln!(out, "  Private keys: {}", found_keys.join(", "));
9455            } else {
9456                out.push_str("  Private keys: none found\n");
9457            }
9458
9459            let config_path = ssh_dir.join("config");
9460            if config_path.exists() {
9461                out.push_str("\n=== SSH config hosts ===\n");
9462                match fs::read_to_string(&config_path) {
9463                    Ok(content) => {
9464                        let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
9465                        let mut current: Option<(String, Vec<String>)> = None;
9466                        for line in content.lines() {
9467                            let t = line.trim();
9468                            if t.is_empty() || t.starts_with('#') {
9469                                continue;
9470                            }
9471                            if let Some(host) = t.strip_prefix("Host ") {
9472                                if let Some(prev) = current.take() {
9473                                    hosts.push(prev);
9474                                }
9475                                current = Some((host.trim().to_string(), Vec::new()));
9476                            } else if let Some((_, ref mut details)) = current {
9477                                let tu = t.to_uppercase();
9478                                if tu.starts_with("HOSTNAME ")
9479                                    || tu.starts_with("USER ")
9480                                    || tu.starts_with("PORT ")
9481                                    || tu.starts_with("IDENTITYFILE ")
9482                                {
9483                                    details.push(t.to_string());
9484                                }
9485                            }
9486                        }
9487                        if let Some(prev) = current {
9488                            hosts.push(prev);
9489                        }
9490
9491                        if hosts.is_empty() {
9492                            out.push_str("  No Host entries found.\n");
9493                        } else {
9494                            for (h, details) in &hosts {
9495                                if details.is_empty() {
9496                                    let _ = writeln!(out, "  Host {h}");
9497                                } else {
9498                                    let _ = writeln!(out, "  Host {h}  [{}]", details.join(", "));
9499                                }
9500                            }
9501                            let _ = write!(out, "\n  Total configured hosts: {}\n", hosts.len());
9502                        }
9503                    }
9504                    Err(e) => {
9505                        let _ = writeln!(out, "  Could not read config: {e}");
9506                    }
9507                }
9508            } else {
9509                out.push_str("  SSH config: not present\n");
9510            }
9511        } else {
9512            out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
9513        }
9514    }
9515
9516    Ok(out.trim_end().to_string())
9517}
9518
9519// ── installed_software ────────────────────────────────────────────────────────
9520
9521fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
9522    let mut out = String::from("Host inspection: installed_software\n\n");
9523    let n = max_entries.clamp(10, 50);
9524
9525    #[cfg(target_os = "windows")]
9526    {
9527        let winget_out = Command::new("winget")
9528            .args(["list", "--accept-source-agreements"])
9529            .output();
9530
9531        if let Ok(o) = winget_out {
9532            if o.status.success() {
9533                let raw = String::from_utf8_lossy(&o.stdout);
9534                let mut header_done = false;
9535                let mut packages: Vec<&str> = Vec::new();
9536                for line in raw.lines() {
9537                    let t = line.trim();
9538                    if t.starts_with("---") {
9539                        header_done = true;
9540                        continue;
9541                    }
9542                    if header_done && !t.is_empty() {
9543                        packages.push(line);
9544                    }
9545                }
9546                let total = packages.len();
9547                let _ = write!(
9548                    out,
9549                    "=== Installed software via winget ({total} packages) ===\n\n"
9550                );
9551                for line in packages.iter().take(n) {
9552                    let _ = writeln!(out, "  {line}");
9553                }
9554                if total > n {
9555                    let _ = write!(out, "\n  ... and {} more packages\n", total - n);
9556                }
9557                out.push_str("\nFor full list: winget list\n");
9558                return Ok(out.trim_end().to_string());
9559            }
9560        }
9561
9562        // Fallback: registry scan
9563        let script = format!(
9564            r#"
9565$apps = @()
9566$reg_paths = @(
9567    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
9568    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
9569    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
9570)
9571foreach ($p in $reg_paths) {{
9572    try {{
9573        $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
9574            Where-Object {{ $_.DisplayName }} |
9575            Select-Object DisplayName, DisplayVersion, Publisher
9576    }} catch {{}}
9577}}
9578$sorted = $apps | Sort-Object DisplayName -Unique
9579"TOTAL:" + $sorted.Count
9580$sorted | Select-Object -First {n} | ForEach-Object {{
9581    $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
9582}}
9583"#
9584        );
9585        if let Ok(o) = Command::new("powershell")
9586            .args(["-NoProfile", "-Command", &script])
9587            .output()
9588        {
9589            let raw = String::from_utf8_lossy(&o.stdout);
9590            out.push_str("=== Installed software (registry scan) ===\n");
9591            let _ = writeln!(out, "  {:<50} {:<18} Publisher", "Name", "Version");
9592            let _ = writeln!(out, "  {}", "-".repeat(90));
9593            for line in raw.lines() {
9594                if let Some(rest) = line.strip_prefix("TOTAL:") {
9595                    let total: usize = rest.trim().parse().unwrap_or(0);
9596                    let _ = write!(out, "  (Total: {total}, showing first {n})\n\n");
9597                } else if !line.trim().is_empty() {
9598                    let mut it = line.splitn(3, '|');
9599                    let name = it.next().map(str::trim).unwrap_or("");
9600                    let ver = it.next().map(str::trim).unwrap_or("");
9601                    let pub_ = it.next().map(str::trim).unwrap_or("");
9602                    let _ = writeln!(out, "  {:<50} {:<18} {pub_}", name, ver);
9603                }
9604            }
9605        } else {
9606            out.push_str(
9607                "Could not query installed software (winget and registry scan both failed).\n",
9608            );
9609        }
9610    }
9611
9612    #[cfg(target_os = "linux")]
9613    {
9614        let mut found = false;
9615        if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
9616            if o.status.success() {
9617                let raw = String::from_utf8_lossy(&o.stdout);
9618                let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
9619                let total = installed.len();
9620                let _ = write!(out, "=== Installed packages via dpkg ({total}) ===\n");
9621                for line in installed.iter().take(n) {
9622                    let _ = write!(out, "  {}\n", line.trim());
9623                }
9624                if total > n {
9625                    let _ = write!(out, "  ... and {} more\n", total - n);
9626                }
9627                out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
9628                found = true;
9629            }
9630        }
9631        if !found {
9632            if let Ok(o) = Command::new("rpm")
9633                .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
9634                .output()
9635            {
9636                if o.status.success() {
9637                    let raw = String::from_utf8_lossy(&o.stdout);
9638                    let lines: Vec<&str> = raw.lines().collect();
9639                    let total = lines.len();
9640                    let _ = write!(out, "=== Installed packages via rpm ({total}) ===\n");
9641                    for line in lines.iter().take(n) {
9642                        let _ = write!(out, "  {line}\n");
9643                    }
9644                    if total > n {
9645                        let _ = write!(out, "  ... and {} more\n", total - n);
9646                    }
9647                    found = true;
9648                }
9649            }
9650        }
9651        if !found {
9652            if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
9653                if o.status.success() {
9654                    let raw = String::from_utf8_lossy(&o.stdout);
9655                    let lines: Vec<&str> = raw.lines().collect();
9656                    let total = lines.len();
9657                    let _ = write!(out, "=== Installed packages via pacman ({total}) ===\n");
9658                    for line in lines.iter().take(n) {
9659                        let _ = write!(out, "  {line}\n");
9660                    }
9661                    if total > n {
9662                        let _ = write!(out, "  ... and {} more\n", total - n);
9663                    }
9664                    found = true;
9665                }
9666            }
9667        }
9668        if !found {
9669            out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
9670        }
9671    }
9672
9673    #[cfg(target_os = "macos")]
9674    {
9675        if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
9676            if o.status.success() {
9677                let raw = String::from_utf8_lossy(&o.stdout);
9678                let lines: Vec<&str> = raw.lines().collect();
9679                let total = lines.len();
9680                let _ = write!(out, "=== Homebrew packages ({total}) ===\n");
9681                for line in lines.iter().take(n) {
9682                    let _ = write!(out, "  {line}\n");
9683                }
9684                if total > n {
9685                    let _ = write!(out, "  ... and {} more\n", total - n);
9686                }
9687                out.push_str("\nFor full list: brew list --versions\n");
9688            }
9689        } else {
9690            out.push_str("Homebrew not found.\n");
9691        }
9692        if let Ok(o) = Command::new("mas").args(["list"]).output() {
9693            if o.status.success() {
9694                let raw = String::from_utf8_lossy(&o.stdout);
9695                let lines: Vec<&str> = raw.lines().collect();
9696                let _ = write!(out, "\n=== Mac App Store apps ({}) ===\n", lines.len());
9697                for line in lines.iter().take(n) {
9698                    let _ = write!(out, "  {line}\n");
9699                }
9700            }
9701        }
9702    }
9703
9704    Ok(out.trim_end().to_string())
9705}
9706
9707// ── git_config ────────────────────────────────────────────────────────────────
9708
9709fn inspect_git_config() -> Result<String, String> {
9710    let mut out = String::from("Host inspection: git_config\n\n");
9711
9712    if let Ok(o) = Command::new("git").args(["--version"]).output() {
9713        let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
9714        let _ = write!(out, "Git: {ver}\n\n");
9715    } else {
9716        out.push_str("Git: not found on PATH.\n");
9717        return Ok(out.trim_end().to_string());
9718    }
9719
9720    if let Ok(o) = Command::new("git")
9721        .args(["config", "--global", "--list"])
9722        .output()
9723    {
9724        if o.status.success() {
9725            let raw = String::from_utf8_lossy(&o.stdout);
9726            let mut pairs: Vec<(String, String)> = raw
9727                .lines()
9728                .filter_map(|l| {
9729                    let mut parts = l.splitn(2, '=');
9730                    let k = parts.next()?.trim().to_string();
9731                    let v = parts.next().unwrap_or("").trim().to_string();
9732                    Some((k, v))
9733                })
9734                .collect();
9735            pairs.sort_by(|a, b| a.0.cmp(&b.0));
9736
9737            out.push_str("=== Global git config ===\n");
9738
9739            let sections: &[(&str, &[&str])] = &[
9740                ("Identity", &["user.name", "user.email", "user.signingkey"]),
9741                (
9742                    "Core",
9743                    &[
9744                        "core.editor",
9745                        "core.autocrlf",
9746                        "core.eol",
9747                        "core.ignorecase",
9748                        "core.filemode",
9749                    ],
9750                ),
9751                (
9752                    "Commit/Signing",
9753                    &[
9754                        "commit.gpgsign",
9755                        "tag.gpgsign",
9756                        "gpg.format",
9757                        "gpg.ssh.allowedsignersfile",
9758                    ],
9759                ),
9760                (
9761                    "Push/Pull",
9762                    &[
9763                        "push.default",
9764                        "push.autosetupremote",
9765                        "pull.rebase",
9766                        "pull.ff",
9767                    ],
9768                ),
9769                ("Credential", &["credential.helper"]),
9770                ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
9771            ];
9772
9773            let mut shown_keys: HashSet<String> = HashSet::new();
9774            for (section, keys) in sections {
9775                let mut section_lines: Vec<String> = Vec::new();
9776                for key in *keys {
9777                    if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
9778                        section_lines.push(format!("  {k} = {v}"));
9779                        shown_keys.insert(k.clone());
9780                    }
9781                }
9782                if !section_lines.is_empty() {
9783                    let _ = write!(out, "\n[{section}]\n");
9784                    for line in section_lines {
9785                        let _ = writeln!(out, "{line}");
9786                    }
9787                }
9788            }
9789
9790            let other: Vec<&(String, String)> = pairs
9791                .iter()
9792                .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
9793                .collect();
9794            if !other.is_empty() {
9795                out.push_str("\n[Other]\n");
9796                for (k, v) in other.iter().take(20) {
9797                    let _ = writeln!(out, "  {k} = {v}");
9798                }
9799                if other.len() > 20 {
9800                    let _ = writeln!(out, "  ... and {} more", other.len() - 20);
9801                }
9802            }
9803
9804            let _ = write!(out, "\nTotal global config keys: {}\n", pairs.len());
9805        } else {
9806            out.push_str("No global git config found.\n");
9807            out.push_str("Set up with:\n");
9808            out.push_str("  git config --global user.name \"Your Name\"\n");
9809            out.push_str("  git config --global user.email \"you@example.com\"\n");
9810        }
9811    }
9812
9813    if let Ok(o) = Command::new("git")
9814        .args(["config", "--local", "--list"])
9815        .output()
9816    {
9817        if o.status.success() {
9818            let raw = String::from_utf8_lossy(&o.stdout);
9819            let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9820            if !lines.is_empty() {
9821                let _ = write!(out, "\n=== Local repo config ({} keys) ===\n", lines.len());
9822                for line in lines.iter().take(15) {
9823                    let _ = writeln!(out, "  {line}");
9824                }
9825                if lines.len() > 15 {
9826                    let _ = writeln!(out, "  ... and {} more", lines.len() - 15);
9827                }
9828            }
9829        }
9830    }
9831
9832    if let Ok(o) = Command::new("git")
9833        .args(["config", "--global", "--get-regexp", r"alias\."])
9834        .output()
9835    {
9836        if o.status.success() {
9837            let raw = String::from_utf8_lossy(&o.stdout);
9838            let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
9839            if !aliases.is_empty() {
9840                let _ = write!(out, "\n=== Git aliases ({}) ===\n", aliases.len());
9841                for a in aliases.iter().take(20) {
9842                    let _ = writeln!(out, "  {a}");
9843                }
9844                if aliases.len() > 20 {
9845                    let _ = writeln!(out, "  ... and {} more", aliases.len() - 20);
9846                }
9847            }
9848        }
9849    }
9850
9851    Ok(out.trim_end().to_string())
9852}
9853
9854// ── databases ─────────────────────────────────────────────────────────────────
9855
9856fn inspect_databases() -> Result<String, String> {
9857    let mut out = String::from("Host inspection: databases\n\n");
9858    out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
9859
9860    struct DbEngine {
9861        name: &'static str,
9862        service_names: &'static [&'static str],
9863        default_port: u16,
9864        cli_name: &'static str,
9865        cli_version_args: &'static [&'static str],
9866    }
9867
9868    let engines: &[DbEngine] = &[
9869        DbEngine {
9870            name: "PostgreSQL",
9871            service_names: &[
9872                "postgresql",
9873                "postgresql-x64-14",
9874                "postgresql-x64-15",
9875                "postgresql-x64-16",
9876                "postgresql-x64-17",
9877            ],
9878
9879            default_port: 5432,
9880            cli_name: "psql",
9881            cli_version_args: &["--version"],
9882        },
9883        DbEngine {
9884            name: "MySQL",
9885            service_names: &["mysql", "mysql80", "mysql57"],
9886
9887            default_port: 3306,
9888            cli_name: "mysql",
9889            cli_version_args: &["--version"],
9890        },
9891        DbEngine {
9892            name: "MariaDB",
9893            service_names: &["mariadb", "mariadb.exe"],
9894
9895            default_port: 3306,
9896            cli_name: "mariadb",
9897            cli_version_args: &["--version"],
9898        },
9899        DbEngine {
9900            name: "MongoDB",
9901            service_names: &["mongodb", "mongod"],
9902
9903            default_port: 27017,
9904            cli_name: "mongod",
9905            cli_version_args: &["--version"],
9906        },
9907        DbEngine {
9908            name: "Redis",
9909            service_names: &["redis", "redis-server"],
9910
9911            default_port: 6379,
9912            cli_name: "redis-server",
9913            cli_version_args: &["--version"],
9914        },
9915        DbEngine {
9916            name: "SQL Server",
9917            service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
9918
9919            default_port: 1433,
9920            cli_name: "sqlcmd",
9921            cli_version_args: &["-?"],
9922        },
9923        DbEngine {
9924            name: "SQLite",
9925            service_names: &[], // no service — file-based
9926
9927            default_port: 0, // no port — file-based
9928            cli_name: "sqlite3",
9929            cli_version_args: &["--version"],
9930        },
9931        DbEngine {
9932            name: "CouchDB",
9933            service_names: &["couchdb", "apache-couchdb"],
9934
9935            default_port: 5984,
9936            cli_name: "couchdb",
9937            cli_version_args: &["--version"],
9938        },
9939        DbEngine {
9940            name: "Cassandra",
9941            service_names: &["cassandra"],
9942
9943            default_port: 9042,
9944            cli_name: "cqlsh",
9945            cli_version_args: &["--version"],
9946        },
9947        DbEngine {
9948            name: "Elasticsearch",
9949            service_names: &["elasticsearch-service-x64", "elasticsearch"],
9950
9951            default_port: 9200,
9952            cli_name: "elasticsearch",
9953            cli_version_args: &["--version"],
9954        },
9955    ];
9956
9957    // Helper: check if port is listening
9958    fn port_listening(port: u16) -> bool {
9959        if port == 0 {
9960            return false;
9961        }
9962        // Use netstat-style check via connecting
9963        std::net::TcpStream::connect_timeout(
9964            &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
9965            std::time::Duration::from_millis(150),
9966        )
9967        .is_ok()
9968    }
9969
9970    let mut found_any = false;
9971
9972    for engine in engines {
9973        let mut status_parts: Vec<String> = Vec::new();
9974        let mut detected = false;
9975
9976        // 1. CLI version check (fastest — works cross-platform)
9977        let version = Command::new(engine.cli_name)
9978            .args(engine.cli_version_args)
9979            .output()
9980            .ok()
9981            .and_then(|o| {
9982                let combined = if o.stdout.is_empty() {
9983                    String::from_utf8_lossy(&o.stderr).trim().to_string()
9984                } else {
9985                    String::from_utf8_lossy(&o.stdout).trim().to_string()
9986                };
9987                // Take just the first line
9988                combined.lines().next().map(|l| l.trim().to_string())
9989            });
9990
9991        if let Some(ref ver) = version {
9992            if !ver.is_empty() {
9993                status_parts.push(format!("version: {ver}"));
9994                detected = true;
9995            }
9996        }
9997
9998        // 2. Port check
9999        if engine.default_port > 0 && port_listening(engine.default_port) {
10000            status_parts.push(format!("listening on :{}", engine.default_port));
10001            detected = true;
10002        } else if engine.default_port > 0 && detected {
10003            status_parts.push(format!("not listening on :{}", engine.default_port));
10004        }
10005
10006        // 3. Windows service check
10007        #[cfg(target_os = "windows")]
10008        {
10009            if !engine.service_names.is_empty() {
10010                let service_list = engine.service_names.join("','");
10011                let script = format!(
10012                    r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
10013                    service_list
10014                );
10015                if let Ok(o) = Command::new("powershell")
10016                    .args(["-NoProfile", "-Command", &script])
10017                    .output()
10018                {
10019                    let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
10020                    if !text.is_empty() {
10021                        let mut it = text.splitn(2, ':');
10022                        let svc_name = it.next().map(str::trim).unwrap_or("");
10023                        let svc_state = it.next().map(str::trim).unwrap_or("unknown");
10024                        status_parts.push(format!("service '{svc_name}': {svc_state}"));
10025                        detected = true;
10026                    }
10027                }
10028            }
10029        }
10030
10031        // 4. Linux/macOS systemctl / launchctl check
10032        #[cfg(not(target_os = "windows"))]
10033        {
10034            for svc in engine.service_names {
10035                if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
10036                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
10037                    if !state.is_empty() && state != "inactive" {
10038                        status_parts.push(format!("systemd '{svc}': {state}"));
10039                        detected = true;
10040                        break;
10041                    }
10042                }
10043            }
10044        }
10045
10046        if detected {
10047            found_any = true;
10048            let label = if engine.default_port > 0 {
10049                format!("{} (default port: {})", engine.name, engine.default_port)
10050            } else {
10051                format!("{} (file-based, no port)", engine.name)
10052            };
10053            let _ = writeln!(out, "[FOUND] {label}");
10054            for part in &status_parts {
10055                let _ = writeln!(out, "  {part}");
10056            }
10057            out.push('\n');
10058        }
10059    }
10060
10061    if !found_any {
10062        out.push_str("No local database engines detected.\n");
10063        out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
10064        out.push_str(
10065            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
10066        );
10067    } else {
10068        out.push_str("---\n");
10069        out.push_str(
10070            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
10071        );
10072        out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
10073    }
10074
10075    Ok(out.trim_end().to_string())
10076}
10077
10078// ── user_accounts ─────────────────────────────────────────────────────────────
10079
10080fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
10081    let mut out = String::from("Host inspection: user_accounts\n\n");
10082
10083    #[cfg(target_os = "windows")]
10084    {
10085        let users_out = Command::new("powershell")
10086            .args([
10087                "-NoProfile", "-NonInteractive", "-Command",
10088                "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \"  $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
10089            ])
10090            .output()
10091            .ok()
10092            .and_then(|o| String::from_utf8(o.stdout).ok())
10093            .unwrap_or_default();
10094
10095        out.push_str("=== Local User Accounts ===\n");
10096        if users_out.trim().is_empty() {
10097            out.push_str("  (requires elevation or Get-LocalUser unavailable)\n");
10098        } else {
10099            for line in users_out.lines().take(max_entries) {
10100                if !line.trim().is_empty() {
10101                    out.push_str(line);
10102                    out.push('\n');
10103                }
10104            }
10105        }
10106
10107        let admins_out = Command::new("powershell")
10108            .args([
10109                "-NoProfile", "-NonInteractive", "-Command",
10110                "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \"  $($_.ObjectClass): $($_.Name)\" }",
10111            ])
10112            .output()
10113            .ok()
10114            .and_then(|o| String::from_utf8(o.stdout).ok())
10115            .unwrap_or_default();
10116
10117        out.push_str("\n=== Administrators Group Members ===\n");
10118        if admins_out.trim().is_empty() {
10119            out.push_str("  (unable to retrieve)\n");
10120        } else {
10121            out.push_str(admins_out.trim());
10122            out.push('\n');
10123        }
10124
10125        let sessions_out = Command::new("powershell")
10126            .args([
10127                "-NoProfile",
10128                "-NonInteractive",
10129                "-Command",
10130                "query user 2>$null",
10131            ])
10132            .output()
10133            .ok()
10134            .and_then(|o| String::from_utf8(o.stdout).ok())
10135            .unwrap_or_default();
10136
10137        out.push_str("\n=== Active Logon Sessions ===\n");
10138        if sessions_out.trim().is_empty() {
10139            out.push_str("  (none or requires elevation)\n");
10140        } else {
10141            for line in sessions_out.lines().take(max_entries) {
10142                if !line.trim().is_empty() {
10143                    let _ = writeln!(out, "  {}", line);
10144                }
10145            }
10146        }
10147
10148        let is_admin = Command::new("powershell")
10149            .args([
10150                "-NoProfile", "-NonInteractive", "-Command",
10151                "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
10152            ])
10153            .output()
10154            .ok()
10155            .and_then(|o| String::from_utf8(o.stdout).ok())
10156            .map(|s| s.trim().to_lowercase())
10157            .unwrap_or_default();
10158
10159        out.push_str("\n=== Current Session Elevation ===\n");
10160        let _ = writeln!(
10161            out,
10162            "  Running as Administrator: {}",
10163            if is_admin.contains("true") {
10164                "YES"
10165            } else {
10166                "no"
10167            }
10168        );
10169    }
10170
10171    #[cfg(not(target_os = "windows"))]
10172    {
10173        let who_out = Command::new("who")
10174            .output()
10175            .ok()
10176            .and_then(|o| String::from_utf8(o.stdout).ok())
10177            .unwrap_or_default();
10178        out.push_str("=== Active Sessions ===\n");
10179        if who_out.trim().is_empty() {
10180            out.push_str("  (none)\n");
10181        } else {
10182            for line in who_out.lines().take(max_entries) {
10183                let _ = write!(out, "  {}\n", line);
10184            }
10185        }
10186        let id_out = Command::new("id")
10187            .output()
10188            .ok()
10189            .and_then(|o| String::from_utf8(o.stdout).ok())
10190            .unwrap_or_default();
10191        let _ = write!(out, "\n=== Current User ===\n  {}\n", id_out.trim());
10192    }
10193
10194    Ok(out.trim_end().to_string())
10195}
10196
10197// ── audit_policy ──────────────────────────────────────────────────────────────
10198
10199fn inspect_audit_policy() -> Result<String, String> {
10200    let mut out = String::from("Host inspection: audit_policy\n\n");
10201
10202    #[cfg(target_os = "windows")]
10203    {
10204        let auditpol_out = Command::new("auditpol")
10205            .args(["/get", "/category:*"])
10206            .output()
10207            .ok()
10208            .and_then(|o| String::from_utf8(o.stdout).ok())
10209            .unwrap_or_default();
10210
10211        if auditpol_out.trim().is_empty()
10212            || auditpol_out.to_lowercase().contains("access is denied")
10213        {
10214            out.push_str("Audit policy requires Administrator elevation to read.\n");
10215            out.push_str(
10216                "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
10217            );
10218        } else {
10219            out.push_str("=== Windows Audit Policy ===\n");
10220            let mut any_enabled = false;
10221            for line in auditpol_out.lines() {
10222                let trimmed = line.trim();
10223                if trimmed.is_empty() {
10224                    continue;
10225                }
10226                if trimmed.contains("Success") || trimmed.contains("Failure") {
10227                    let _ = writeln!(out, "  [ENABLED] {}", trimmed);
10228                    any_enabled = true;
10229                } else {
10230                    let _ = writeln!(out, "  {}", trimmed);
10231                }
10232            }
10233            if !any_enabled {
10234                out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
10235                out.push_str(
10236                    "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
10237                );
10238            }
10239        }
10240
10241        let evtlog = Command::new("powershell")
10242            .args([
10243                "-NoProfile", "-NonInteractive", "-Command",
10244                "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
10245            ])
10246            .output()
10247            .ok()
10248            .and_then(|o| String::from_utf8(o.stdout).ok())
10249            .map(|s| s.trim().to_string())
10250            .unwrap_or_default();
10251
10252        let _ = write!(
10253            out,
10254            "\n=== Windows Event Log Service ===\n  Status: {}\n",
10255            if evtlog.is_empty() {
10256                "unknown".to_string()
10257            } else {
10258                evtlog
10259            }
10260        );
10261    }
10262
10263    #[cfg(not(target_os = "windows"))]
10264    {
10265        let auditd_status = Command::new("systemctl")
10266            .args(["is-active", "auditd"])
10267            .output()
10268            .ok()
10269            .and_then(|o| String::from_utf8(o.stdout).ok())
10270            .map(|s| s.trim().to_string())
10271            .unwrap_or_else(|| "not found".to_string());
10272
10273        let _ = write!(out, "=== auditd service ===\n  Status: {}\n", auditd_status);
10274
10275        if auditd_status == "active" {
10276            let rules = Command::new("auditctl")
10277                .args(["-l"])
10278                .output()
10279                .ok()
10280                .and_then(|o| String::from_utf8(o.stdout).ok())
10281                .unwrap_or_default();
10282            out.push_str("\n=== Active Audit Rules ===\n");
10283            if rules.trim().is_empty() || rules.contains("No rules") {
10284                out.push_str("  No rules configured.\n");
10285            } else {
10286                for line in rules.lines() {
10287                    let _ = write!(out, "  {}\n", line);
10288                }
10289            }
10290        }
10291    }
10292
10293    Ok(out.trim_end().to_string())
10294}
10295
10296// ── shares ────────────────────────────────────────────────────────────────────
10297
10298fn inspect_shares(max_entries: usize) -> Result<String, String> {
10299    let mut out = String::from("Host inspection: shares\n\n");
10300
10301    #[cfg(target_os = "windows")]
10302    {
10303        let smb_out = Command::new("powershell")
10304            .args([
10305                "-NoProfile", "-NonInteractive", "-Command",
10306                "Get-SmbShare | ForEach-Object { \"  $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
10307            ])
10308            .output()
10309            .ok()
10310            .and_then(|o| String::from_utf8(o.stdout).ok())
10311            .unwrap_or_default();
10312
10313        out.push_str("=== SMB Shares (exposed by this machine) ===\n");
10314        let smb_lines: Vec<&str> = smb_out
10315            .lines()
10316            .filter(|l| !l.trim().is_empty())
10317            .take(max_entries)
10318            .collect();
10319        if smb_lines.is_empty() {
10320            out.push_str("  No SMB shares or unable to retrieve.\n");
10321        } else {
10322            for line in &smb_lines {
10323                let name = line.trim().split('|').next().unwrap_or("").trim();
10324                if name.ends_with('$') {
10325                    let _ = writeln!(out, "  {}", line.trim());
10326                } else {
10327                    let _ = writeln!(out, "  [CUSTOM] {}", line.trim());
10328                }
10329            }
10330        }
10331
10332        let smb_security = Command::new("powershell")
10333            .args([
10334                "-NoProfile", "-NonInteractive", "-Command",
10335                "Get-SmbServerConfiguration | ForEach-Object { \"  SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
10336            ])
10337            .output()
10338            .ok()
10339            .and_then(|o| String::from_utf8(o.stdout).ok())
10340            .unwrap_or_default();
10341
10342        out.push_str("\n=== SMB Server Security Settings ===\n");
10343        if smb_security.trim().is_empty() {
10344            out.push_str("  (unable to retrieve)\n");
10345        } else {
10346            out.push_str(smb_security.trim());
10347            out.push('\n');
10348            if smb_security.to_lowercase().contains("smb1: true") {
10349                out.push_str("  [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
10350            }
10351        }
10352
10353        let drives_out = Command::new("powershell")
10354            .args([
10355                "-NoProfile", "-NonInteractive", "-Command",
10356                "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \"  $($_.Name): -> $($_.DisplayRoot)\" }",
10357            ])
10358            .output()
10359            .ok()
10360            .and_then(|o| String::from_utf8(o.stdout).ok())
10361            .unwrap_or_default();
10362
10363        out.push_str("\n=== Mapped Network Drives ===\n");
10364        if drives_out.trim().is_empty() {
10365            out.push_str("  None.\n");
10366        } else {
10367            for line in drives_out.lines().take(max_entries) {
10368                if !line.trim().is_empty() {
10369                    out.push_str(line);
10370                    out.push('\n');
10371                }
10372            }
10373        }
10374    }
10375
10376    #[cfg(not(target_os = "windows"))]
10377    {
10378        let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
10379        out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
10380        if smb_conf.is_empty() {
10381            out.push_str("  Not found or Samba not installed.\n");
10382        } else {
10383            for line in smb_conf.lines().take(max_entries) {
10384                let _ = write!(out, "  {}\n", line);
10385            }
10386        }
10387        let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
10388        out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
10389        if nfs_exports.is_empty() {
10390            out.push_str("  Not configured.\n");
10391        } else {
10392            for line in nfs_exports.lines().take(max_entries) {
10393                let _ = write!(out, "  {}\n", line);
10394            }
10395        }
10396    }
10397
10398    Ok(out.trim_end().to_string())
10399}
10400
10401// ── dns_servers ───────────────────────────────────────────────────────────────
10402
10403fn inspect_dns_servers() -> Result<String, String> {
10404    let mut out = String::from("Host inspection: dns_servers\n\n");
10405
10406    #[cfg(target_os = "windows")]
10407    {
10408        let dns_out = Command::new("powershell")
10409            .args([
10410                "-NoProfile", "-NonInteractive", "-Command",
10411                "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \"  $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
10412            ])
10413            .output()
10414            .ok()
10415            .and_then(|o| String::from_utf8(o.stdout).ok())
10416            .unwrap_or_default();
10417
10418        out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
10419        if dns_out.trim().is_empty() {
10420            out.push_str("  (unable to retrieve)\n");
10421        } else {
10422            for line in dns_out.lines() {
10423                if line.trim().is_empty() {
10424                    continue;
10425                }
10426                let mut annotation = "";
10427                if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
10428                    annotation = "  <- Google Public DNS";
10429                } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
10430                    annotation = "  <- Cloudflare DNS";
10431                } else if line.contains("9.9.9.9") {
10432                    annotation = "  <- Quad9";
10433                } else if line.contains("208.67.222") || line.contains("208.67.220") {
10434                    annotation = "  <- OpenDNS";
10435                }
10436                out.push_str(line);
10437                out.push_str(annotation);
10438                out.push('\n');
10439            }
10440        }
10441
10442        let doh_out = Command::new("powershell")
10443            .args([
10444                "-NoProfile", "-NonInteractive", "-Command",
10445                "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \"  $($_.ServerAddress): $($_.DohTemplate)\" }",
10446            ])
10447            .output()
10448            .ok()
10449            .and_then(|o| String::from_utf8(o.stdout).ok())
10450            .unwrap_or_default();
10451
10452        out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
10453        if doh_out.trim().is_empty() {
10454            out.push_str("  Not configured (plain DNS).\n");
10455        } else {
10456            out.push_str(doh_out.trim());
10457            out.push('\n');
10458        }
10459
10460        let suffixes = Command::new("powershell")
10461            .args([
10462                "-NoProfile", "-NonInteractive", "-Command",
10463                "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \"  $_\" }",
10464            ])
10465            .output()
10466            .ok()
10467            .and_then(|o| String::from_utf8(o.stdout).ok())
10468            .unwrap_or_default();
10469
10470        if !suffixes.trim().is_empty() {
10471            out.push_str("\n=== DNS Search Suffix List ===\n");
10472            out.push_str(suffixes.trim());
10473            out.push('\n');
10474        }
10475    }
10476
10477    #[cfg(not(target_os = "windows"))]
10478    {
10479        let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
10480        out.push_str("=== /etc/resolv.conf ===\n");
10481        if resolv.is_empty() {
10482            out.push_str("  Not found.\n");
10483        } else {
10484            for line in resolv.lines() {
10485                if !line.trim().is_empty() && !line.starts_with('#') {
10486                    let _ = write!(out, "  {}\n", line);
10487                }
10488            }
10489        }
10490        let resolved_out = Command::new("resolvectl")
10491            .args(["status", "--no-pager"])
10492            .output()
10493            .ok()
10494            .and_then(|o| String::from_utf8(o.stdout).ok())
10495            .unwrap_or_default();
10496        if !resolved_out.is_empty() {
10497            out.push_str("\n=== systemd-resolved ===\n");
10498            for line in resolved_out.lines().take(30) {
10499                let _ = write!(out, "  {}\n", line);
10500            }
10501        }
10502    }
10503
10504    Ok(out.trim_end().to_string())
10505}
10506
10507fn inspect_bitlocker() -> Result<String, String> {
10508    let mut out = String::from("Host inspection: bitlocker\n\n");
10509
10510    #[cfg(target_os = "windows")]
10511    {
10512        let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
10513        let output = Command::new("powershell")
10514            .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
10515            .output()
10516            .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
10517
10518        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10519        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
10520
10521        if !stdout.trim().is_empty() {
10522            out.push_str("=== BitLocker Volumes ===\n");
10523            for line in stdout.lines() {
10524                let _ = writeln!(out, "  {}", line);
10525            }
10526        } else if !stderr.trim().is_empty() {
10527            if stderr.contains("Access is denied") {
10528                out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
10529            } else {
10530                let _ = writeln!(out, "Error retrieving BitLocker info: {}", stderr.trim());
10531            }
10532        } else {
10533            out.push_str("No BitLocker volumes detected or access denied.\n");
10534        }
10535    }
10536
10537    #[cfg(not(target_os = "windows"))]
10538    {
10539        out.push_str(
10540            "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
10541        );
10542        let lsblk = Command::new("lsblk")
10543            .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
10544            .output()
10545            .ok()
10546            .and_then(|o| String::from_utf8(o.stdout).ok())
10547            .unwrap_or_default();
10548        if lsblk.contains("crypto_LUKS") {
10549            out.push_str("=== LUKS Encrypted Volumes ===\n");
10550            for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
10551                let _ = write!(out, "  {}\n", line);
10552            }
10553        } else {
10554            out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
10555        }
10556    }
10557
10558    Ok(out.trim_end().to_string())
10559}
10560
10561fn inspect_rdp() -> Result<String, String> {
10562    let mut out = String::from("Host inspection: rdp\n\n");
10563
10564    #[cfg(target_os = "windows")]
10565    {
10566        let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
10567        let f_deny = Command::new("powershell")
10568            .args([
10569                "-NoProfile",
10570                "-Command",
10571                &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
10572            ])
10573            .output()
10574            .ok()
10575            .and_then(|o| String::from_utf8(o.stdout).ok())
10576            .unwrap_or_default()
10577            .trim()
10578            .to_string();
10579
10580        let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
10581        let _ = writeln!(out, "=== RDP Status: {} ===", status);
10582
10583        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"])
10584            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
10585        let _ = writeln!(
10586            out,
10587            "  Port: {}",
10588            if port.is_empty() {
10589                "3389 (default)"
10590            } else {
10591                &port
10592            }
10593        );
10594
10595        let nla = Command::new("powershell")
10596            .args([
10597                "-NoProfile",
10598                "-Command",
10599                &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
10600            ])
10601            .output()
10602            .ok()
10603            .and_then(|o| String::from_utf8(o.stdout).ok())
10604            .unwrap_or_default()
10605            .trim()
10606            .to_string();
10607        let _ = writeln!(
10608            out,
10609            "  NLA Required: {}",
10610            if nla == "1" { "Yes" } else { "No" }
10611        );
10612
10613        let rdp_tcp_path =
10614            "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp";
10615        let sec_layer = Command::new("powershell")
10616            .args([
10617                "-NoProfile",
10618                "-Command",
10619                &format!("(Get-ItemProperty '{}').SecurityLayer", rdp_tcp_path),
10620            ])
10621            .output()
10622            .ok()
10623            .and_then(|o| String::from_utf8(o.stdout).ok())
10624            .unwrap_or_default()
10625            .trim()
10626            .to_string();
10627        let sec_label = match sec_layer.as_str() {
10628            "0" => "RDP Security (no SSL)",
10629            "1" => "Negotiate (prefer TLS)",
10630            "2" => "SSL/TLS required",
10631            _ => &sec_layer,
10632        };
10633        let _ = writeln!(out, "  Security Layer: {} ({})", sec_layer, sec_label);
10634
10635        let enc_level = Command::new("powershell")
10636            .args([
10637                "-NoProfile",
10638                "-Command",
10639                &format!("(Get-ItemProperty '{}').MinEncryptionLevel", rdp_tcp_path),
10640            ])
10641            .output()
10642            .ok()
10643            .and_then(|o| String::from_utf8(o.stdout).ok())
10644            .unwrap_or_default()
10645            .trim()
10646            .to_string();
10647        let enc_label = match enc_level.as_str() {
10648            "1" => "Low",
10649            "2" => "Client Compatible",
10650            "3" => "High",
10651            "4" => "FIPS Compliant",
10652            _ => "Unknown",
10653        };
10654        let _ = writeln!(out, "  Encryption Level: {} ({})", enc_level, enc_label);
10655
10656        out.push_str("\n=== Active Sessions ===\n");
10657        let qwinsta = Command::new("qwinsta")
10658            .output()
10659            .ok()
10660            .and_then(|o| String::from_utf8(o.stdout).ok())
10661            .unwrap_or_default();
10662        if qwinsta.trim().is_empty() {
10663            out.push_str("  No active sessions listed.\n");
10664        } else {
10665            for line in qwinsta.lines() {
10666                let _ = writeln!(out, "  {}", line);
10667            }
10668        }
10669
10670        out.push_str("\n=== Firewall Rule Check ===\n");
10671        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))\" }"])
10672            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10673        if fw.trim().is_empty() {
10674            out.push_str("  No enabled RDP firewall rules found.\n");
10675        } else {
10676            out.push_str(fw.trim_end());
10677            out.push('\n');
10678        }
10679    }
10680
10681    #[cfg(not(target_os = "windows"))]
10682    {
10683        out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
10684        let ss = Command::new("ss")
10685            .args(["-tlnp"])
10686            .output()
10687            .ok()
10688            .and_then(|o| String::from_utf8(o.stdout).ok())
10689            .unwrap_or_default();
10690        let matches: Vec<&str> = ss
10691            .lines()
10692            .filter(|l| l.contains(":3389") || l.contains(":590"))
10693            .collect();
10694        if matches.is_empty() {
10695            out.push_str("  No RDP/VNC listeners detected via 'ss'.\n");
10696        } else {
10697            for m in matches {
10698                let _ = write!(out, "  {}\n", m);
10699            }
10700        }
10701    }
10702
10703    Ok(out.trim_end().to_string())
10704}
10705
10706fn inspect_shadow_copies() -> Result<String, String> {
10707    let mut out = String::from("Host inspection: shadow_copies\n\n");
10708
10709    #[cfg(target_os = "windows")]
10710    {
10711        let output = Command::new("vssadmin")
10712            .args(["list", "shadows"])
10713            .output()
10714            .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
10715        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
10716
10717        if stdout.contains("No items found") || stdout.trim().is_empty() {
10718            out.push_str("No Volume Shadow Copies found.\n");
10719        } else {
10720            out.push_str("=== Volume Shadow Copies ===\n");
10721            for line in stdout.lines().take(50) {
10722                if line.contains("Creation Time:")
10723                    || line.contains("Contents:")
10724                    || line.contains("Volume Name:")
10725                {
10726                    let _ = writeln!(out, "  {}", line.trim());
10727                }
10728            }
10729        }
10730
10731        // Most recent snapshot age
10732        let age_script = r#"
10733try {
10734    $snaps = Get-WmiObject Win32_ShadowCopy -ErrorAction SilentlyContinue | Sort-Object InstallDate -Descending
10735    if ($snaps) {
10736        $newest = $snaps[0]
10737        $created = [Management.ManagementDateTimeConverter]::ToDateTime($newest.InstallDate)
10738        $age = [math]::Round(([datetime]::Now - $created).TotalDays, 1)
10739        $count = @($snaps).Count
10740        "Most recent snapshot: $($created.ToString('yyyy-MM-dd HH:mm'))  ($age days ago)  — $count total snapshots"
10741    } else { "No snapshots found via WMI." }
10742} catch { "WMI snapshot query unavailable: $_" }
10743"#;
10744        if let Ok(age_out) = Command::new("powershell")
10745            .args(["-NoProfile", "-Command", age_script])
10746            .output()
10747        {
10748            let age_text = String::from_utf8_lossy(&age_out.stdout).trim().to_string();
10749            if !age_text.is_empty() {
10750                out.push_str("\n=== Snapshot Age ===\n");
10751                let _ = writeln!(out, "  {}", age_text);
10752            }
10753        }
10754
10755        out.push_str("\n=== Shadow Copy Storage ===\n");
10756        let storage_out = Command::new("vssadmin")
10757            .args(["list", "shadowstorage"])
10758            .output()
10759            .ok();
10760        if let Some(o) = storage_out {
10761            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10762            for line in stdout.lines() {
10763                if line.contains("Used Shadow Copy Storage space:")
10764                    || line.contains("Max Shadow Copy Storage space:")
10765                {
10766                    let _ = writeln!(out, "  {}", line.trim());
10767                }
10768            }
10769        }
10770    }
10771
10772    #[cfg(not(target_os = "windows"))]
10773    {
10774        out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
10775        let lvs = Command::new("lvs")
10776            .output()
10777            .ok()
10778            .and_then(|o| String::from_utf8(o.stdout).ok())
10779            .unwrap_or_default();
10780        if !lvs.is_empty() {
10781            out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
10782            out.push_str(&lvs);
10783        } else {
10784            out.push_str("No LVM volumes detected.\n");
10785        }
10786    }
10787
10788    Ok(out.trim_end().to_string())
10789}
10790
10791fn inspect_pagefile() -> Result<String, String> {
10792    let mut out = String::from("Host inspection: pagefile\n\n");
10793
10794    #[cfg(target_os = "windows")]
10795    {
10796        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)\" }";
10797        let output = Command::new("powershell")
10798            .args(["-NoProfile", "-Command", ps_cmd])
10799            .output()
10800            .ok()
10801            .and_then(|o| String::from_utf8(o.stdout).ok())
10802            .unwrap_or_default();
10803
10804        if output.trim().is_empty() {
10805            out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
10806            let managed = Command::new("powershell")
10807                .args([
10808                    "-NoProfile",
10809                    "-Command",
10810                    "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
10811                ])
10812                .output()
10813                .ok()
10814                .and_then(|o| String::from_utf8(o.stdout).ok())
10815                .unwrap_or_default()
10816                .trim()
10817                .to_string();
10818            let _ = writeln!(out, "Automatic Managed Pagefile: {}", managed);
10819        } else {
10820            out.push_str("=== Page File Usage ===\n");
10821            out.push_str(&output);
10822        }
10823    }
10824
10825    #[cfg(not(target_os = "windows"))]
10826    {
10827        out.push_str("=== Swap Usage (Linux/macOS) ===\n");
10828        let swap = Command::new("swapon")
10829            .args(["--show"])
10830            .output()
10831            .ok()
10832            .and_then(|o| String::from_utf8(o.stdout).ok())
10833            .unwrap_or_default();
10834        if swap.is_empty() {
10835            let free = Command::new("free")
10836                .args(["-h"])
10837                .output()
10838                .ok()
10839                .and_then(|o| String::from_utf8(o.stdout).ok())
10840                .unwrap_or_default();
10841            out.push_str(&free);
10842        } else {
10843            out.push_str(&swap);
10844        }
10845    }
10846
10847    Ok(out.trim_end().to_string())
10848}
10849
10850fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
10851    let mut out = String::from("Host inspection: windows_features\n\n");
10852
10853    #[cfg(target_os = "windows")]
10854    {
10855        out.push_str("=== Quick Check: Notable Features ===\n");
10856        let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
10857        let output = Command::new("powershell")
10858            .args(["-NoProfile", "-Command", quick_ps])
10859            .output()
10860            .ok();
10861
10862        if let Some(o) = output {
10863            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10864            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10865
10866            if !stdout.trim().is_empty() {
10867                for f in stdout.lines() {
10868                    let _ = writeln!(out, "  [ENABLED] {}", f);
10869                }
10870            } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
10871                out.push_str("  Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
10872            } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
10873                out.push_str(
10874                    "  No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
10875                );
10876            }
10877        }
10878
10879        let _ = write!(
10880            out,
10881            "\n=== All Enabled Features (capped at {}) ===\n",
10882            max_entries
10883        );
10884        let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
10885        let all_out = Command::new("powershell")
10886            .args(["-NoProfile", "-Command", &all_ps])
10887            .output()
10888            .ok();
10889        if let Some(o) = all_out {
10890            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10891            if !stdout.trim().is_empty() {
10892                out.push_str(&stdout);
10893            }
10894        }
10895    }
10896
10897    #[cfg(not(target_os = "windows"))]
10898    {
10899        let _ = max_entries;
10900        out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
10901    }
10902
10903    Ok(out.trim_end().to_string())
10904}
10905
10906fn inspect_audio(max_entries: usize) -> Result<String, String> {
10907    let mut out = String::from("Host inspection: audio\n\n");
10908
10909    #[cfg(target_os = "windows")]
10910    {
10911        let n = max_entries.clamp(5, 20);
10912        let services = collect_services().unwrap_or_default();
10913        let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
10914        let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
10915
10916        let core_services: Vec<&ServiceEntry> = services
10917            .iter()
10918            .filter(|entry| {
10919                core_service_names
10920                    .iter()
10921                    .any(|name| entry.name.eq_ignore_ascii_case(name))
10922            })
10923            .collect();
10924        let bluetooth_audio_services: Vec<&ServiceEntry> = services
10925            .iter()
10926            .filter(|entry| {
10927                bluetooth_audio_service_names
10928                    .iter()
10929                    .any(|name| entry.name.eq_ignore_ascii_case(name))
10930            })
10931            .collect();
10932
10933        let probe_script = r#"
10934$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
10935    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10936$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10937    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10938$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
10939    Select-Object Name, Status, Manufacturer, PNPDeviceID)
10940[pscustomobject]@{
10941    Media = $media
10942    Endpoints = $endpoints
10943    SoundDevices = $sound
10944} | ConvertTo-Json -Compress -Depth 4
10945"#;
10946        let probe_raw = Command::new("powershell")
10947            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10948            .output()
10949            .ok()
10950            .and_then(|o| String::from_utf8(o.stdout).ok())
10951            .unwrap_or_default();
10952        let probe_loaded = !probe_raw.trim().is_empty();
10953        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10954
10955        let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
10956        let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
10957        let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
10958
10959        let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
10960            .iter()
10961            .filter(|device| !is_microphone_like_name(&device.name))
10962            .collect();
10963        let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
10964            .iter()
10965            .filter(|device| is_microphone_like_name(&device.name))
10966            .collect();
10967        let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
10968            .iter()
10969            .filter(|device| is_bluetooth_like_name(&device.name))
10970            .collect();
10971        let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
10972            .iter()
10973            .filter(|device| windows_device_has_issue(device))
10974            .collect();
10975        let media_problems: Vec<&WindowsPnpDevice> = media_devices
10976            .iter()
10977            .filter(|device| windows_device_has_issue(device))
10978            .collect();
10979        let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
10980            .iter()
10981            .filter(|device| windows_sound_device_has_issue(device))
10982            .collect();
10983
10984        let mut findings = Vec::with_capacity(4);
10985
10986        let stopped_core_services: Vec<&ServiceEntry> = core_services
10987            .iter()
10988            .copied()
10989            .filter(|service| !service_is_running(service))
10990            .collect();
10991        if !stopped_core_services.is_empty() {
10992            let names = {
10993                let mut s = String::new();
10994                for (i, svc) in stopped_core_services.iter().enumerate() {
10995                    if i > 0 {
10996                        s.push_str(", ");
10997                    }
10998                    s.push_str(&svc.name);
10999                }
11000                s
11001            };
11002            findings.push(AuditFinding {
11003                finding: format!("Core audio services are not running: {names}"),
11004                impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
11005                fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
11006            });
11007        }
11008
11009        if probe_loaded
11010            && endpoints.is_empty()
11011            && media_devices.is_empty()
11012            && sound_devices.is_empty()
11013        {
11014            findings.push(AuditFinding {
11015                finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
11016                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(),
11017                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(),
11018            });
11019        }
11020
11021        if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
11022        {
11023            let mut problem_labels = Vec::with_capacity(9);
11024            problem_labels.extend(
11025                endpoint_problems
11026                    .iter()
11027                    .take(3)
11028                    .map(|device| device.name.clone()),
11029            );
11030            problem_labels.extend(
11031                media_problems
11032                    .iter()
11033                    .take(3)
11034                    .map(|device| device.name.clone()),
11035            );
11036            problem_labels.extend(
11037                sound_problems
11038                    .iter()
11039                    .take(3)
11040                    .map(|device| device.name.clone()),
11041            );
11042            findings.push(AuditFinding {
11043                finding: format!(
11044                    "Windows reports audio device issues for: {}",
11045                    problem_labels.join(", ")
11046                ),
11047                impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
11048                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(),
11049            });
11050        }
11051
11052        let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
11053            .iter()
11054            .copied()
11055            .filter(|service| !service_is_running(service))
11056            .collect();
11057        if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
11058            let names = {
11059                let mut s = String::new();
11060                for (i, svc) in stopped_bt_audio_services.iter().enumerate() {
11061                    if i > 0 {
11062                        s.push_str(", ");
11063                    }
11064                    s.push_str(&svc.name);
11065                }
11066                s
11067            };
11068            findings.push(AuditFinding {
11069                finding: format!(
11070                    "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
11071                ),
11072                impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
11073                fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
11074            });
11075        }
11076
11077        out.push_str("=== Findings ===\n");
11078        if findings.is_empty() {
11079            out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
11080            out.push_str("  Impact: Playback and recording look structurally present from this inspection pass.\n");
11081            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");
11082        } else {
11083            for finding in &findings {
11084                let _ = writeln!(out, "- Finding: {}", finding.finding);
11085                let _ = writeln!(out, "  Impact: {}", finding.impact);
11086                let _ = writeln!(out, "  Fix: {}", finding.fix);
11087            }
11088        }
11089
11090        out.push_str("\n=== Audio services ===\n");
11091        if core_services.is_empty() && bluetooth_audio_services.is_empty() {
11092            out.push_str(
11093                "- No Windows audio services were retrieved from the service inventory.\n",
11094            );
11095        } else {
11096            for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
11097                let _ = writeln!(
11098                    out,
11099                    "- {} | Status: {} | Startup: {}",
11100                    service.name,
11101                    service.status,
11102                    service.startup.as_deref().unwrap_or("Unknown")
11103                );
11104            }
11105        }
11106
11107        out.push_str("\n=== Playback and recording endpoints ===\n");
11108        if !probe_loaded {
11109            out.push_str("- Windows endpoint inventory probe returned no data.\n");
11110        } else if endpoints.is_empty() {
11111            out.push_str("- No audio endpoints detected.\n");
11112        } else {
11113            let _ = writeln!(
11114                out,
11115                "- Playback-style endpoints: {} | Recording-style endpoints: {}",
11116                playback_endpoints.len(),
11117                recording_endpoints.len()
11118            );
11119            for device in playback_endpoints.iter().take(n) {
11120                let _ = writeln!(
11121                    out,
11122                    "- [PLAYBACK] {} | Status: {}{}",
11123                    device.name,
11124                    device.status,
11125                    device
11126                        .problem
11127                        .filter(|problem| *problem != 0)
11128                        .map(|problem| format!(" | ProblemCode: {problem}"))
11129                        .unwrap_or_default()
11130                );
11131            }
11132            for device in recording_endpoints.iter().take(n) {
11133                let _ = writeln!(
11134                    out,
11135                    "- [MIC] {} | Status: {}{}",
11136                    device.name,
11137                    device.status,
11138                    device
11139                        .problem
11140                        .filter(|problem| *problem != 0)
11141                        .map(|problem| format!(" | ProblemCode: {problem}"))
11142                        .unwrap_or_default()
11143                );
11144            }
11145        }
11146
11147        out.push_str("\n=== Sound hardware devices ===\n");
11148        if sound_devices.is_empty() {
11149            out.push_str("- No Win32_SoundDevice entries were returned.\n");
11150        } else {
11151            for device in sound_devices.iter().take(n) {
11152                let _ = writeln!(
11153                    out,
11154                    "- {} | Status: {}{}",
11155                    device.name,
11156                    device.status,
11157                    device
11158                        .manufacturer
11159                        .as_deref()
11160                        .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
11161                        .unwrap_or_default()
11162                );
11163            }
11164        }
11165
11166        out.push_str("\n=== Media-class device inventory ===\n");
11167        if media_devices.is_empty() {
11168            out.push_str("- No media-class PnP devices were returned.\n");
11169        } else {
11170            for device in media_devices.iter().take(n) {
11171                let _ = writeln!(
11172                    out,
11173                    "- {} | Status: {}{}",
11174                    device.name,
11175                    device.status,
11176                    device
11177                        .class_name
11178                        .as_deref()
11179                        .map(|class_name| format!(" | Class: {class_name}"))
11180                        .unwrap_or_default()
11181                );
11182            }
11183        }
11184    }
11185
11186    #[cfg(not(target_os = "windows"))]
11187    {
11188        let _ = max_entries;
11189        out.push_str(
11190            "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
11191        );
11192        out.push_str(
11193            "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
11194        );
11195    }
11196
11197    Ok(out.trim_end().to_string())
11198}
11199
11200fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
11201    let mut out = String::from("Host inspection: bluetooth\n\n");
11202
11203    #[cfg(target_os = "windows")]
11204    {
11205        let n = max_entries.clamp(5, 20);
11206        let services = collect_services().unwrap_or_default();
11207        let bluetooth_services: Vec<&ServiceEntry> = services
11208            .iter()
11209            .filter(|entry| {
11210                entry.name.eq_ignore_ascii_case("bthserv")
11211                    || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
11212                    || entry.name.eq_ignore_ascii_case("BTAGService")
11213                    || entry.name.starts_with("BluetoothUserService")
11214                    || entry
11215                        .display_name
11216                        .as_deref()
11217                        .unwrap_or("")
11218                        .to_ascii_lowercase()
11219                        .contains("bluetooth")
11220            })
11221            .collect();
11222
11223        let probe_script = r#"
11224$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
11225    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
11226$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
11227    Where-Object {
11228        $_.Class -eq 'Bluetooth' -or
11229        $_.FriendlyName -match 'Bluetooth' -or
11230        $_.InstanceId -like 'BTH*'
11231    } |
11232    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
11233$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
11234    Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
11235    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
11236[pscustomobject]@{
11237    Radios = $radios
11238    Devices = $devices
11239    AudioEndpoints = $audio
11240} | ConvertTo-Json -Compress -Depth 4
11241"#;
11242        let probe_raw = Command::new("powershell")
11243            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
11244            .output()
11245            .ok()
11246            .and_then(|o| String::from_utf8(o.stdout).ok())
11247            .unwrap_or_default();
11248        let probe_loaded = !probe_raw.trim().is_empty();
11249        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
11250
11251        let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
11252        let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
11253        let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
11254        let radio_problems: Vec<&WindowsPnpDevice> = radios
11255            .iter()
11256            .filter(|device| windows_device_has_issue(device))
11257            .collect();
11258        let device_problems: Vec<&WindowsPnpDevice> = devices
11259            .iter()
11260            .filter(|device| windows_device_has_issue(device))
11261            .collect();
11262
11263        let mut findings = Vec::with_capacity(4);
11264
11265        if probe_loaded && radios.is_empty() {
11266            findings.push(AuditFinding {
11267                finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
11268                impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
11269                fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
11270            });
11271        }
11272
11273        let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
11274            .iter()
11275            .copied()
11276            .filter(|service| !service_is_running(service))
11277            .collect();
11278        if !stopped_bluetooth_services.is_empty() {
11279            let names = {
11280                let mut s = String::new();
11281                for (i, svc) in stopped_bluetooth_services.iter().enumerate() {
11282                    if i > 0 {
11283                        s.push_str(", ");
11284                    }
11285                    s.push_str(&svc.name);
11286                }
11287                s
11288            };
11289            findings.push(AuditFinding {
11290                finding: format!("Bluetooth-related services are not fully running: {names}"),
11291                impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
11292                fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
11293            });
11294        }
11295
11296        if !radio_problems.is_empty() || !device_problems.is_empty() {
11297            let problem_labels = {
11298                let mut s = String::new();
11299                for (i, device) in radio_problems
11300                    .iter()
11301                    .chain(device_problems.iter())
11302                    .take(5)
11303                    .enumerate()
11304                {
11305                    if i > 0 {
11306                        s.push_str(", ");
11307                    }
11308                    s.push_str(&device.name);
11309                }
11310                s
11311            };
11312            findings.push(AuditFinding {
11313                finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
11314                impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
11315                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(),
11316            });
11317        }
11318
11319        if !audio_endpoints.is_empty()
11320            && bluetooth_services
11321                .iter()
11322                .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
11323            && bluetooth_services
11324                .iter()
11325                .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
11326                .any(|service| !service_is_running(service))
11327        {
11328            findings.push(AuditFinding {
11329                finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
11330                impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
11331                fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
11332            });
11333        }
11334
11335        out.push_str("=== Findings ===\n");
11336        if findings.is_empty() {
11337            out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
11338            out.push_str("  Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
11339            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");
11340        } else {
11341            for finding in &findings {
11342                let _ = writeln!(out, "- Finding: {}", finding.finding);
11343                let _ = writeln!(out, "  Impact: {}", finding.impact);
11344                let _ = writeln!(out, "  Fix: {}", finding.fix);
11345            }
11346        }
11347
11348        out.push_str("\n=== Bluetooth services ===\n");
11349        if bluetooth_services.is_empty() {
11350            out.push_str(
11351                "- No Bluetooth-related services were retrieved from the service inventory.\n",
11352            );
11353        } else {
11354            for service in bluetooth_services.iter().take(n) {
11355                let _ = writeln!(
11356                    out,
11357                    "- {} | Status: {} | Startup: {}",
11358                    service.name,
11359                    service.status,
11360                    service.startup.as_deref().unwrap_or("Unknown")
11361                );
11362            }
11363        }
11364
11365        out.push_str("\n=== Bluetooth radios and adapters ===\n");
11366        if !probe_loaded {
11367            out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
11368        } else if radios.is_empty() {
11369            out.push_str("- No Bluetooth radios detected.\n");
11370        } else {
11371            for device in radios.iter().take(n) {
11372                let _ = writeln!(
11373                    out,
11374                    "- {} | Status: {}{}",
11375                    device.name,
11376                    device.status,
11377                    device
11378                        .problem
11379                        .filter(|problem| *problem != 0)
11380                        .map(|problem| format!(" | ProblemCode: {problem}"))
11381                        .unwrap_or_default()
11382                );
11383            }
11384        }
11385
11386        out.push_str("\n=== Bluetooth-associated devices ===\n");
11387        if devices.is_empty() {
11388            out.push_str("- No Bluetooth-associated device nodes detected.\n");
11389        } else {
11390            for device in devices.iter().take(n) {
11391                let _ = writeln!(
11392                    out,
11393                    "- {} | Status: {}{}",
11394                    device.name,
11395                    device.status,
11396                    device
11397                        .class_name
11398                        .as_deref()
11399                        .map(|class_name| format!(" | Class: {class_name}"))
11400                        .unwrap_or_default()
11401                );
11402            }
11403        }
11404
11405        out.push_str("\n=== Bluetooth audio endpoints ===\n");
11406        if audio_endpoints.is_empty() {
11407            out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
11408        } else {
11409            for device in audio_endpoints.iter().take(n) {
11410                let _ = writeln!(
11411                    out,
11412                    "- {} | Status: {}{}",
11413                    device.name,
11414                    device.status,
11415                    device
11416                        .instance_id
11417                        .as_deref()
11418                        .map(|instance_id| format!(" | Instance: {instance_id}"))
11419                        .unwrap_or_default()
11420                );
11421            }
11422        }
11423    }
11424
11425    #[cfg(not(target_os = "windows"))]
11426    {
11427        let _ = max_entries;
11428        out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
11429        out.push_str(
11430            "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
11431        );
11432    }
11433
11434    Ok(out.trim_end().to_string())
11435}
11436
11437fn inspect_printers(max_entries: usize) -> Result<String, String> {
11438    let mut out = String::from("Host inspection: printers\n\n");
11439
11440    #[cfg(target_os = "windows")]
11441    {
11442        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)])
11443            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11444        if list.trim().is_empty() {
11445            out.push_str("No printers detected.\n");
11446        } else {
11447            out.push_str("=== Installed Printers ===\n");
11448            out.push_str(&list);
11449        }
11450
11451        let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \"  [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
11452            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11453        if !jobs.trim().is_empty() {
11454            out.push_str("\n=== Active Print Jobs ===\n");
11455            out.push_str(&jobs);
11456        }
11457    }
11458
11459    #[cfg(not(target_os = "windows"))]
11460    {
11461        let _ = max_entries;
11462        out.push_str("Checking LPSTAT for printers...\n");
11463        let lpstat = Command::new("lpstat")
11464            .args(["-p", "-d"])
11465            .output()
11466            .ok()
11467            .and_then(|o| String::from_utf8(o.stdout).ok())
11468            .unwrap_or_default();
11469        if lpstat.is_empty() {
11470            out.push_str("  No CUPS/LP printers found.\n");
11471        } else {
11472            out.push_str(&lpstat);
11473        }
11474    }
11475
11476    Ok(out.trim_end().to_string())
11477}
11478
11479fn inspect_winrm() -> Result<String, String> {
11480    let mut out = String::from("Host inspection: winrm\n\n");
11481
11482    #[cfg(target_os = "windows")]
11483    {
11484        let svc = Command::new("powershell")
11485            .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
11486            .output()
11487            .ok()
11488            .and_then(|o| String::from_utf8(o.stdout).ok())
11489            .unwrap_or_default()
11490            .trim()
11491            .to_string();
11492        let _ = write!(
11493            out,
11494            "WinRM Service Status: {}\n\n",
11495            if svc.is_empty() { "NOT_FOUND" } else { &svc }
11496        );
11497
11498        out.push_str("=== WinRM Listeners ===\n");
11499        let output = Command::new("powershell")
11500            .args([
11501                "-NoProfile",
11502                "-Command",
11503                "winrm enumerate winrm/config/listener 2>$null",
11504            ])
11505            .output()
11506            .ok();
11507        if let Some(o) = output {
11508            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11509            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11510
11511            if !stdout.trim().is_empty() {
11512                for line in stdout.lines() {
11513                    if line.contains("Address =")
11514                        || line.contains("Transport =")
11515                        || line.contains("Port =")
11516                    {
11517                        let _ = writeln!(out, "  {}", line.trim());
11518                    }
11519                }
11520            } else if stderr.contains("Access is denied") {
11521                out.push_str("  Error: Access denied to WinRM configuration.\n");
11522            } else {
11523                out.push_str("  No listeners configured.\n");
11524            }
11525        }
11526
11527        out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
11528        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))\" }"])
11529            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11530        if test_out.trim().is_empty() {
11531            out.push_str("  WinRM not responding to local WS-Man requests.\n");
11532        } else {
11533            out.push_str(&test_out);
11534        }
11535    }
11536
11537    #[cfg(not(target_os = "windows"))]
11538    {
11539        out.push_str(
11540            "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
11541        );
11542        let ss = Command::new("ss")
11543            .args(["-tln"])
11544            .output()
11545            .ok()
11546            .and_then(|o| String::from_utf8(o.stdout).ok())
11547            .unwrap_or_default();
11548        if ss.contains(":5985") || ss.contains(":5986") {
11549            out.push_str("  WinRM ports (5985/5986) are listening.\n");
11550        } else {
11551            out.push_str("  WinRM ports not detected.\n");
11552        }
11553    }
11554
11555    Ok(out.trim_end().to_string())
11556}
11557
11558fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
11559    let mut out = String::from("Host inspection: network_stats\n\n");
11560
11561    #[cfg(target_os = "windows")]
11562    {
11563        let ps_cmd = format!(
11564            "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
11565             Start-Sleep -Milliseconds 250; \
11566             $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
11567             $s2 | ForEach-Object {{ \
11568                $name = $_.Name; \
11569                $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
11570                if ($prev) {{ \
11571                    $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
11572                    $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
11573                    $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
11574                    $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
11575                    $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
11576                    $tt = [math]::Round($_.SentBytes / 1MB, 2); \
11577                    \"  $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
11578                }} \
11579             }}",
11580            max_entries
11581        );
11582        let output = Command::new("powershell")
11583            .args(["-NoProfile", "-Command", &ps_cmd])
11584            .output()
11585            .ok()
11586            .and_then(|o| String::from_utf8(o.stdout).ok())
11587            .unwrap_or_default();
11588        if output.trim().is_empty() {
11589            out.push_str("No network adapter statistics available.\n");
11590        } else {
11591            out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
11592            out.push_str(&output);
11593        }
11594
11595        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)\" } }"])
11596            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
11597        if !discards.trim().is_empty() {
11598            out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
11599            out.push_str(&discards);
11600        }
11601    }
11602
11603    #[cfg(not(target_os = "windows"))]
11604    {
11605        let _ = max_entries;
11606        out.push_str("=== Network Stats (ip -s link) ===\n");
11607        let ip_s = Command::new("ip")
11608            .args(["-s", "link"])
11609            .output()
11610            .ok()
11611            .and_then(|o| String::from_utf8(o.stdout).ok())
11612            .unwrap_or_default();
11613        if ip_s.is_empty() {
11614            let netstat = Command::new("netstat")
11615                .args(["-i"])
11616                .output()
11617                .ok()
11618                .and_then(|o| String::from_utf8(o.stdout).ok())
11619                .unwrap_or_default();
11620            out.push_str(&netstat);
11621        } else {
11622            out.push_str(&ip_s);
11623        }
11624    }
11625
11626    Ok(out.trim_end().to_string())
11627}
11628
11629fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
11630    let mut out = String::from("Host inspection: udp_ports\n\n");
11631
11632    #[cfg(target_os = "windows")]
11633    {
11634        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);
11635        let output = Command::new("powershell")
11636            .args(["-NoProfile", "-Command", &ps_cmd])
11637            .output()
11638            .ok();
11639
11640        if let Some(o) = output {
11641            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11642            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11643
11644            if !stdout.trim().is_empty() {
11645                out.push_str("=== UDP Listeners (Local:Port) ===\n");
11646                for line in stdout.lines() {
11647                    let mut note = "";
11648                    if line.contains(":53 ") {
11649                        note = " [DNS]";
11650                    } else if line.contains(":67 ") || line.contains(":68 ") {
11651                        note = " [DHCP]";
11652                    } else if line.contains(":123 ") {
11653                        note = " [NTP]";
11654                    } else if line.contains(":161 ") {
11655                        note = " [SNMP]";
11656                    } else if line.contains(":1900 ") {
11657                        note = " [SSDP/UPnP]";
11658                    } else if line.contains(":5353 ") {
11659                        note = " [mDNS]";
11660                    }
11661
11662                    let _ = writeln!(out, "{}{}", line, note);
11663                }
11664            } else if stderr.contains("Access is denied") {
11665                out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
11666            } else {
11667                out.push_str("No UDP listeners detected.\n");
11668            }
11669        }
11670    }
11671
11672    #[cfg(not(target_os = "windows"))]
11673    {
11674        let ss_out = Command::new("ss")
11675            .args(["-ulnp"])
11676            .output()
11677            .ok()
11678            .and_then(|o| String::from_utf8(o.stdout).ok())
11679            .unwrap_or_default();
11680        out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
11681        if ss_out.is_empty() {
11682            let netstat_out = Command::new("netstat")
11683                .args(["-ulnp"])
11684                .output()
11685                .ok()
11686                .and_then(|o| String::from_utf8(o.stdout).ok())
11687                .unwrap_or_default();
11688            if netstat_out.is_empty() {
11689                out.push_str("  Neither 'ss' nor 'netstat' available.\n");
11690            } else {
11691                for line in netstat_out.lines().take(max_entries) {
11692                    let _ = write!(out, "  {}\n", line);
11693                }
11694            }
11695        } else {
11696            for line in ss_out.lines().take(max_entries) {
11697                let _ = write!(out, "  {}\n", line);
11698            }
11699        }
11700    }
11701
11702    Ok(out.trim_end().to_string())
11703}
11704
11705fn inspect_gpo() -> Result<String, String> {
11706    let mut out = String::from("Host inspection: gpo\n\n");
11707
11708    #[cfg(target_os = "windows")]
11709    {
11710        let output = Command::new("gpresult")
11711            .args(["/r", "/scope", "computer"])
11712            .output()
11713            .ok();
11714
11715        if let Some(o) = output {
11716            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11717            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
11718
11719            if stdout.contains("Applied Group Policy Objects") {
11720                out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
11721                let mut capture = false;
11722                for line in stdout.lines() {
11723                    if line.contains("Applied Group Policy Objects") {
11724                        capture = true;
11725                    } else if capture && line.contains("The following GPOs were not applied") {
11726                        break;
11727                    }
11728                    if capture && !line.trim().is_empty() {
11729                        let _ = writeln!(out, "  {}", line.trim());
11730                    }
11731                }
11732            } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
11733                out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
11734            } else {
11735                out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
11736            }
11737        }
11738    }
11739
11740    #[cfg(not(target_os = "windows"))]
11741    {
11742        out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
11743    }
11744
11745    Ok(out.trim_end().to_string())
11746}
11747
11748fn inspect_certificates(max_entries: usize) -> Result<String, String> {
11749    let mut out = String::from("Host inspection: certificates\n\n");
11750
11751    #[cfg(target_os = "windows")]
11752    {
11753        let ps_cmd = format!(
11754            "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
11755                $days = ($_.NotAfter - (Get-Date)).Days; \
11756                $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
11757                \"  $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
11758            }}", 
11759            max_entries
11760        );
11761        let output = Command::new("powershell")
11762            .args(["-NoProfile", "-Command", &ps_cmd])
11763            .output()
11764            .ok();
11765
11766        if let Some(o) = output {
11767            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11768            if !stdout.trim().is_empty() {
11769                out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
11770                out.push_str(&stdout);
11771            } else {
11772                out.push_str("No certificates found in the Local Machine Personal store.\n");
11773            }
11774        }
11775    }
11776
11777    #[cfg(not(target_os = "windows"))]
11778    {
11779        let _ = max_entries;
11780        out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
11781        // Check standard cert locations
11782        for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
11783            if Path::new(path).exists() {
11784                let _ = write!(out, "  Cert directory found: {}\n", path);
11785            }
11786        }
11787    }
11788
11789    Ok(out.trim_end().to_string())
11790}
11791
11792fn inspect_integrity() -> Result<String, String> {
11793    let mut out = String::from("Host inspection: integrity\n\n");
11794
11795    #[cfg(target_os = "windows")]
11796    {
11797        let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
11798        let output = Command::new("powershell")
11799            .args(["-NoProfile", "-Command", ps_cmd])
11800            .output()
11801            .ok();
11802
11803        if let Some(o) = output {
11804            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11805            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11806                out.push_str("=== Windows Component Store Health (CBS) ===\n");
11807                let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
11808                let repair = val
11809                    .get("AutoRepairNeeded")
11810                    .and_then(|v| v.as_u64())
11811                    .unwrap_or(0);
11812
11813                let _ = writeln!(
11814                    out,
11815                    "  Corruption Detected: {}",
11816                    if corrupt != 0 {
11817                        "YES (SFC/DISM recommended)"
11818                    } else {
11819                        "No"
11820                    }
11821                );
11822                let _ = writeln!(
11823                    out,
11824                    "  Auto-Repair Needed: {}",
11825                    if repair != 0 { "YES" } else { "No" }
11826                );
11827
11828                if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
11829                    let _ = writeln!(out, "  Last Repair Attempt: (Raw code: {})", last);
11830                }
11831            } else {
11832                out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
11833            }
11834        }
11835
11836        if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
11837            out.push_str(
11838                "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
11839            );
11840        }
11841    }
11842
11843    #[cfg(not(target_os = "windows"))]
11844    {
11845        out.push_str("System integrity check (Linux)\n\n");
11846        let pkg_check = Command::new("rpm")
11847            .args(["-Va"])
11848            .output()
11849            .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
11850            .ok();
11851        if let Some(o) = pkg_check {
11852            out.push_str("  Package verification system active.\n");
11853            if o.status.success() {
11854                out.push_str("  No major package integrity issues detected.\n");
11855            }
11856        }
11857    }
11858
11859    Ok(out.trim_end().to_string())
11860}
11861
11862fn inspect_domain() -> Result<String, String> {
11863    let mut out = String::from("Host inspection: domain\n\n");
11864
11865    #[cfg(target_os = "windows")]
11866    {
11867        out.push_str("=== Windows Domain / Workgroup Identity ===\n");
11868        let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
11869        let output = Command::new("powershell")
11870            .args(["-NoProfile", "-Command", ps_cmd])
11871            .output()
11872            .ok();
11873
11874        if let Some(o) = output {
11875            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
11876            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
11877                let part_of_domain = val
11878                    .get("PartOfDomain")
11879                    .and_then(|v| v.as_bool())
11880                    .unwrap_or(false);
11881                let domain = val
11882                    .get("Domain")
11883                    .and_then(|v| v.as_str())
11884                    .unwrap_or("Unknown");
11885                let workgroup = val
11886                    .get("Workgroup")
11887                    .and_then(|v| v.as_str())
11888                    .unwrap_or("Unknown");
11889
11890                let _ = writeln!(
11891                    out,
11892                    "  Join Status: {}",
11893                    if part_of_domain {
11894                        "DOMAIN JOINED"
11895                    } else {
11896                        "WORKGROUP"
11897                    }
11898                );
11899                if part_of_domain {
11900                    let _ = writeln!(out, "  Active Directory Domain: {}", domain);
11901                } else {
11902                    let _ = writeln!(out, "  Workgroup Name: {}", workgroup);
11903                }
11904
11905                if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
11906                    let _ = writeln!(out, "  NetBIOS Name: {}", name);
11907                }
11908            } else {
11909                out.push_str("  Domain identity data unavailable from WMI.\n");
11910            }
11911        } else {
11912            out.push_str("  Domain identity data unavailable from WMI.\n");
11913        }
11914    }
11915
11916    #[cfg(not(target_os = "windows"))]
11917    {
11918        let domainname = Command::new("domainname")
11919            .output()
11920            .ok()
11921            .and_then(|o| String::from_utf8(o.stdout).ok())
11922            .unwrap_or_default();
11923        out.push_str("=== Linux Domain Identity ===\n");
11924        if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
11925            let _ = write!(out, "  NIS/YP Domain: {}\n", domainname.trim());
11926        } else {
11927            out.push_str("  No NIS domain configured.\n");
11928        }
11929    }
11930
11931    Ok(out.trim_end().to_string())
11932}
11933
11934fn inspect_device_health() -> Result<String, String> {
11935    let mut out = String::from("Host inspection: device_health\n\n");
11936
11937    #[cfg(target_os = "windows")]
11938    {
11939        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)\" }";
11940        let output = Command::new("powershell")
11941            .args(["-NoProfile", "-Command", ps_cmd])
11942            .output()
11943            .ok()
11944            .and_then(|o| String::from_utf8(o.stdout).ok())
11945            .unwrap_or_default();
11946
11947        if output.trim().is_empty() {
11948            out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
11949        } else {
11950            out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
11951            out.push_str(&output);
11952            out.push_str(
11953                "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
11954            );
11955        }
11956    }
11957
11958    #[cfg(not(target_os = "windows"))]
11959    {
11960        out.push_str("Checking dmesg for hardware errors...\n");
11961        let dmesg = Command::new("dmesg")
11962            .args(["--level=err,crit,alert"])
11963            .output()
11964            .ok()
11965            .and_then(|o| String::from_utf8(o.stdout).ok())
11966            .unwrap_or_default();
11967        if dmesg.is_empty() {
11968            out.push_str("  No critical hardware errors found in dmesg.\n");
11969        } else {
11970            for (i, line) in dmesg.lines().take(20).enumerate() {
11971                if i > 0 {
11972                    out.push('\n');
11973                }
11974                out.push_str(line);
11975            }
11976        }
11977    }
11978
11979    Ok(out.trim_end().to_string())
11980}
11981
11982fn inspect_drivers(max_entries: usize) -> Result<String, String> {
11983    let mut out = String::from("Host inspection: drivers\n\n");
11984
11985    #[cfg(target_os = "windows")]
11986    {
11987        out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
11988        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);
11989        let output = Command::new("powershell")
11990            .args(["-NoProfile", "-Command", &ps_cmd])
11991            .output()
11992            .ok()
11993            .and_then(|o| String::from_utf8(o.stdout).ok())
11994            .unwrap_or_default();
11995
11996        if output.trim().is_empty() {
11997            out.push_str("  No drivers retrieved via WMI.\n");
11998        } else {
11999            out.push_str(&output);
12000        }
12001    }
12002
12003    #[cfg(not(target_os = "windows"))]
12004    {
12005        out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
12006        let lsmod = Command::new("lsmod")
12007            .output()
12008            .ok()
12009            .and_then(|o| String::from_utf8(o.stdout).ok())
12010            .unwrap_or_default();
12011        for (i, line) in lsmod.lines().take(max_entries).enumerate() {
12012            if i > 0 {
12013                out.push('\n');
12014            }
12015            out.push_str(line);
12016        }
12017    }
12018
12019    Ok(out.trim_end().to_string())
12020}
12021
12022fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
12023    let mut out = String::from("Host inspection: peripherals\n\n");
12024
12025    #[cfg(target_os = "windows")]
12026    {
12027        let _ = max_entries;
12028        out.push_str("=== USB Controllers & Hubs ===\n");
12029        let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \"  $($_.Name) ($($_.Status))\" }"])
12030            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
12031        out.push_str(if usb.is_empty() {
12032            "  None detected.\n"
12033        } else {
12034            &usb
12035        });
12036
12037        out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
12038        let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \"  [KB] $($_.Name) ($($_.Status))\" }"])
12039            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
12040        let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \"  [PTR] $($_.Name) ($($_.Status))\" }"])
12041            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
12042        out.push_str(&kb);
12043        out.push_str(&mouse);
12044
12045        out.push_str("\n=== Connected Monitors (WMI) ===\n");
12046        let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \"  Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
12047            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
12048        out.push_str(if mon.is_empty() {
12049            "  No active monitors identified via WMI.\n"
12050        } else {
12051            &mon
12052        });
12053    }
12054
12055    #[cfg(not(target_os = "windows"))]
12056    {
12057        out.push_str("=== Connected USB Devices (lsusb) ===\n");
12058        let lsusb = Command::new("lsusb")
12059            .output()
12060            .ok()
12061            .and_then(|o| String::from_utf8(o.stdout).ok())
12062            .unwrap_or_default();
12063        for (i, line) in lsusb.lines().take(max_entries).enumerate() {
12064            if i > 0 {
12065                out.push('\n');
12066            }
12067            out.push_str(line);
12068        }
12069    }
12070
12071    Ok(out.trim_end().to_string())
12072}
12073
12074fn inspect_sessions(max_entries: usize) -> Result<String, String> {
12075    let mut out = String::from("Host inspection: sessions\n\n");
12076
12077    #[cfg(target_os = "windows")]
12078    {
12079        out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
12080        let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
12081    "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
12082}"#;
12083        if let Ok(o) = Command::new("powershell")
12084            .args(["-NoProfile", "-Command", script])
12085            .output()
12086        {
12087            let text = String::from_utf8_lossy(&o.stdout);
12088            let lines: Vec<&str> = text.lines().collect();
12089            if lines.is_empty() {
12090                out.push_str("  No active logon sessions enumerated via WMI.\n");
12091            } else {
12092                for line in lines
12093                    .iter()
12094                    .take(max_entries)
12095                    .filter(|l| !l.trim().is_empty())
12096                {
12097                    let mut it = line.trim().splitn(5, '|');
12098                    if let (Some(p0), Some(p1), Some(p2), Some(p3)) =
12099                        (it.next(), it.next(), it.next(), it.next())
12100                    {
12101                        let logon_type = match p2 {
12102                            "2" => "Interactive",
12103                            "3" => "Network",
12104                            "4" => "Batch",
12105                            "5" => "Service",
12106                            "7" => "Unlock",
12107                            "8" => "NetworkCleartext",
12108                            "9" => "NewCredentials",
12109                            "10" => "RemoteInteractive",
12110                            "11" => "CachedInteractive",
12111                            _ => "Other",
12112                        };
12113                        let _ = writeln!(
12114                            out,
12115                            "- ID: {} | Type: {} | Started: {} | Auth: {}",
12116                            p0, logon_type, p1, p3
12117                        );
12118                    }
12119                }
12120            }
12121        } else {
12122            out.push_str("  Active logon session data unavailable from WMI.\n");
12123        }
12124    }
12125
12126    #[cfg(not(target_os = "windows"))]
12127    {
12128        out.push_str("=== Logged-in Users (who) ===\n");
12129        let who = Command::new("who")
12130            .output()
12131            .ok()
12132            .and_then(|o| String::from_utf8(o.stdout).ok())
12133            .unwrap_or_default();
12134        for (i, line) in who.lines().take(max_entries).enumerate() {
12135            if i > 0 {
12136                out.push('\n');
12137            }
12138            out.push_str(line);
12139        }
12140    }
12141
12142    Ok(out.trim_end().to_string())
12143}
12144
12145async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
12146    let mut out = String::from("Host inspection: disk_benchmark\n\n");
12147    let mut final_path = path;
12148
12149    if !final_path.exists() {
12150        if let Ok(current_exe) = std::env::current_exe() {
12151            let _ = writeln!(out,
12152                "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.",
12153                final_path.display()
12154            );
12155            final_path = current_exe;
12156        } else {
12157            return Err(format!("Target not found: {}", final_path.display()));
12158        }
12159    }
12160
12161    let target = if final_path.is_dir() {
12162        // Find a representative file to read
12163        let mut target_file = final_path.join("Cargo.toml");
12164        if !target_file.exists() {
12165            target_file = final_path.join("README.md");
12166        }
12167        if !target_file.exists() {
12168            return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
12169        }
12170        target_file
12171    } else {
12172        final_path
12173    };
12174
12175    let _ = writeln!(out, "Target: {}", target.display());
12176    out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
12177
12178    #[cfg(target_os = "windows")]
12179    {
12180        let target_display = target.display().to_string();
12181        let escaped_target = ps_escape_single_quoted(&target_display);
12182        let script = format!(
12183            r#"
12184$target = '{escaped_target}'
12185if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
12186
12187$diskQueue = @()
12188$readStats = @()
12189$startTime = Get-Date
12190$duration = 5
12191
12192# Background reader job
12193$job = Start-Job -ScriptBlock {{
12194    param($t, $d)
12195    $stop = (Get-Date).AddSeconds($d)
12196    while ((Get-Date) -lt $stop) {{
12197        try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
12198    }}
12199}} -ArgumentList $target, $duration
12200
12201# Metrics collector loop
12202$stopTime = (Get-Date).AddSeconds($duration)
12203while ((Get-Date) -lt $stopTime) {{
12204    $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
12205    if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
12206
12207    $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
12208    if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
12209
12210    Start-Sleep -Milliseconds 250
12211}}
12212
12213Stop-Job $job
12214Receive-Job $job | Out-Null
12215Remove-Job $job
12216
12217$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
12218$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
12219$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
12220
12221"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
12222"#
12223        );
12224
12225        let output = Command::new("powershell")
12226            .args(["-NoProfile", "-Command", &script])
12227            .output()
12228            .map_err(|e| format!("Benchmark failed: {e}"))?;
12229
12230        let raw = String::from_utf8_lossy(&output.stdout);
12231        let text = raw.trim();
12232
12233        if text.starts_with("ERROR") {
12234            return Err(text.to_string());
12235        }
12236
12237        let mut lines = text.lines();
12238        if let Some(metrics_line) = lines.next() {
12239            let mut avg_q = "unknown".to_string();
12240            let mut max_q = "unknown".to_string();
12241            let mut avg_r = "unknown".to_string();
12242
12243            for p in metrics_line.split('|') {
12244                if let Some((k, v)) = p.split_once(':') {
12245                    match k {
12246                        "AVG_Q" => avg_q = v.to_string(),
12247                        "MAX_Q" => max_q = v.to_string(),
12248                        "AVG_R" => avg_r = v.to_string(),
12249                        _ => {}
12250                    }
12251                }
12252            }
12253
12254            out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
12255            let _ = writeln!(out, "- Active Disk Queue (Avg): {}", avg_q);
12256            let _ = writeln!(out, "- Active Disk Queue (Max): {}", max_q);
12257            let _ = writeln!(out, "- Disk Throughput (Avg):  {} reads/sec", avg_r);
12258            out.push_str("\nVerdict: ");
12259            let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
12260            if q_num > 1.0 {
12261                out.push_str(
12262                    "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
12263                );
12264            } else if q_num > 0.1 {
12265                out.push_str("MODERATE LOAD — significant I/O pressure detected.");
12266            } else {
12267                out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
12268            }
12269        }
12270    }
12271
12272    #[cfg(not(target_os = "windows"))]
12273    {
12274        out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
12275        out.push_str("Generic disk load simulated.\n");
12276    }
12277
12278    Ok(out)
12279}
12280
12281fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
12282    let mut out = String::from("Host inspection: permissions\n\n");
12283    let _ = write!(out, "Auditing access control for: {}\n\n", path.display());
12284
12285    #[cfg(target_os = "windows")]
12286    {
12287        let path_str = path.display().to_string();
12288        let escaped_path = ps_escape_single_quoted(&path_str);
12289        let script = format!(
12290            "Get-Acl -Path '{escaped_path}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}"
12291        );
12292        let output = Command::new("powershell")
12293            .args(["-NoProfile", "-Command", &script])
12294            .output()
12295            .map_err(|e| format!("ACL check failed: {e}"))?;
12296
12297        let text = String::from_utf8_lossy(&output.stdout);
12298        if text.trim().is_empty() {
12299            out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
12300        } else {
12301            out.push_str("=== Windows NTFS Permissions ===\n");
12302            out.push_str(&text);
12303        }
12304    }
12305
12306    #[cfg(not(target_os = "windows"))]
12307    {
12308        let output = Command::new("ls")
12309            .args(["-ld", &path.to_string_lossy()])
12310            .output()
12311            .map_err(|e| format!("ls check failed: {e}"))?;
12312        out.push_str("=== Unix File Permissions ===\n");
12313        out.push_str(&String::from_utf8_lossy(&output.stdout));
12314    }
12315
12316    Ok(out.trim_end().to_string())
12317}
12318
12319fn inspect_login_history(max_entries: usize) -> Result<String, String> {
12320    let mut out = String::from("Host inspection: login_history\n\n");
12321
12322    #[cfg(target_os = "windows")]
12323    {
12324        out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
12325        out.push_str("Note: This typically requires Administrator elevation.\n\n");
12326
12327        let n = max_entries.clamp(1, 50);
12328        let script = format!(
12329            r#"try {{
12330    $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
12331    $events | ForEach-Object {{
12332        $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
12333        # Extract target user name from the XML/Properties if possible
12334        $user = $_.Properties[5].Value
12335        $type = $_.Properties[8].Value
12336        "[$time] User: $user | Type: $type"
12337    }}
12338}} catch {{ "ERROR:" + $_.Exception.Message }}"#
12339        );
12340
12341        let output = Command::new("powershell")
12342            .args(["-NoProfile", "-Command", &script])
12343            .output()
12344            .map_err(|e| format!("Login history query failed: {e}"))?;
12345
12346        let text = String::from_utf8_lossy(&output.stdout);
12347        if text.starts_with("ERROR:") {
12348            let _ = writeln!(out, "Unable to query Security Log: {}", text);
12349        } else if text.trim().is_empty() {
12350            out.push_str("No recent logon events found or access denied.\n");
12351        } else {
12352            out.push_str("=== Recent Logons (Event ID 4624) ===\n");
12353            out.push_str(&text);
12354        }
12355    }
12356
12357    #[cfg(not(target_os = "windows"))]
12358    {
12359        let output = Command::new("last")
12360            .args(["-n", &max_entries.to_string()])
12361            .output()
12362            .map_err(|e| format!("last command failed: {e}"))?;
12363        out.push_str("=== Unix Login History (last) ===\n");
12364        out.push_str(&String::from_utf8_lossy(&output.stdout));
12365    }
12366
12367    Ok(out.trim_end().to_string())
12368}
12369
12370fn inspect_share_access(path: PathBuf) -> Result<String, String> {
12371    let mut out = String::from("Host inspection: share_access\n\n");
12372    let _ = write!(out, "Testing accessibility of: {}\n\n", path.display());
12373
12374    #[cfg(target_os = "windows")]
12375    {
12376        let path_str = path.display().to_string();
12377        let escaped_path = ps_escape_single_quoted(&path_str);
12378        let script = format!(
12379            r#"
12380$p = '{escaped_path}'
12381$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
12382if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
12383    $res.Reachable = $true
12384    try {{
12385        $null = Get-ChildItem -Path $p -ErrorAction Stop
12386        $res.Readable = $true
12387    }} catch {{
12388        $res.Error = $_.Exception.Message
12389    }}
12390}} else {{
12391    $res.Error = "Server unreachable (Ping failed)"
12392}}
12393"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#
12394        );
12395
12396        let output = Command::new("powershell")
12397            .args(["-NoProfile", "-Command", &script])
12398            .output()
12399            .map_err(|e| format!("Share test failed: {e}"))?;
12400
12401        let text = String::from_utf8_lossy(&output.stdout);
12402        out.push_str("=== Share Triage Results ===\n");
12403        out.push_str(&text);
12404    }
12405
12406    #[cfg(not(target_os = "windows"))]
12407    {
12408        out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
12409    }
12410
12411    Ok(out.trim_end().to_string())
12412}
12413
12414fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
12415    let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
12416    let _ = write!(out, "Issue: {}\n\n", issue);
12417    out.push_str("Proposed Remediation Steps:\n");
12418    out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
12419    out.push_str("   `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
12420    out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
12421    out.push_str("   `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
12422    out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
12423    out.push_str("   `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
12424    out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
12425    out.push_str(
12426        "   `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
12427    );
12428
12429    Ok(out)
12430}
12431
12432fn inspect_registry_audit() -> Result<String, String> {
12433    let mut out = String::from("Host inspection: registry_audit\n\n");
12434    out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
12435
12436    #[cfg(target_os = "windows")]
12437    {
12438        let script = r#"
12439$findings = @()
12440
12441# 1. Image File Execution Options (Debugger Hijacking)
12442$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
12443if (Test-Path $ifeo) {
12444    Get-ChildItem $ifeo | ForEach-Object {
12445        $p = Get-ItemProperty $_.PSPath
12446        if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
12447    }
12448}
12449
12450# 2. Winlogon Shell Integrity
12451$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
12452$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
12453if ($shell -and $shell -ne "explorer.exe") {
12454    $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
12455}
12456
12457# 3. Session Manager BootExecute
12458$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
12459$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
12460if ($boot -and $boot -notcontains "autocheck autochk *") {
12461    $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
12462}
12463
12464if ($findings.Count -eq 0) {
12465    "PASS: No common registry hijacking or shell overrides detected."
12466} else {
12467    $findings -join "`n"
12468}
12469"#;
12470        let output = Command::new("powershell")
12471            .args(["-NoProfile", "-Command", script])
12472            .output()
12473            .map_err(|e| format!("Registry audit failed: {e}"))?;
12474
12475        let text = String::from_utf8_lossy(&output.stdout);
12476        out.push_str("=== Persistence & Integrity Check ===\n");
12477        out.push_str(&text);
12478    }
12479
12480    #[cfg(not(target_os = "windows"))]
12481    {
12482        out.push_str("Registry auditing is specific to Windows environments.\n");
12483    }
12484
12485    Ok(out.trim_end().to_string())
12486}
12487
12488fn inspect_thermal() -> Result<String, String> {
12489    let mut out = String::from("Host inspection: thermal\n\n");
12490    out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
12491
12492    #[cfg(target_os = "windows")]
12493    {
12494        let script = r#"
12495$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
12496if ($thermal) {
12497    $thermal | ForEach-Object {
12498        $temp = [math]::Round(($_.Temperature - 273.15), 1)
12499        "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
12500    }
12501} else {
12502    "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
12503    $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
12504    "Current CPU Load: $throttling%"
12505}
12506"#;
12507        let output = Command::new("powershell")
12508            .args(["-NoProfile", "-Command", script])
12509            .output()
12510            .map_err(|e| format!("Thermal check failed: {e}"))?;
12511        out.push_str("=== Windows Thermal State ===\n");
12512        out.push_str(&String::from_utf8_lossy(&output.stdout));
12513    }
12514
12515    #[cfg(not(target_os = "windows"))]
12516    {
12517        out.push_str(
12518            "Thermal inspection is currently optimized for Windows performance counters.\n",
12519        );
12520    }
12521
12522    Ok(out.trim_end().to_string())
12523}
12524
12525fn inspect_activation() -> Result<String, String> {
12526    let mut out = String::from("Host inspection: activation\n\n");
12527    out.push_str("Auditing Windows activation and license state...\n\n");
12528
12529    #[cfg(target_os = "windows")]
12530    {
12531        let script = r#"
12532$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
12533$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
12534"Status: $($xpr.Trim())"
12535"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
12536"#;
12537        let output = Command::new("powershell")
12538            .args(["-NoProfile", "-Command", script])
12539            .output()
12540            .map_err(|e| format!("Activation check failed: {e}"))?;
12541        out.push_str("=== Windows License Report ===\n");
12542        out.push_str(&String::from_utf8_lossy(&output.stdout));
12543    }
12544
12545    #[cfg(not(target_os = "windows"))]
12546    {
12547        out.push_str("Windows activation check is specific to the Windows platform.\n");
12548    }
12549
12550    Ok(out.trim_end().to_string())
12551}
12552
12553fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
12554    let mut out = String::from("Host inspection: patch_history\n\n");
12555    let _ = write!(
12556        out,
12557        "Listing the last {} installed Windows updates (KBs)...\n\n",
12558        max_entries
12559    );
12560
12561    #[cfg(target_os = "windows")]
12562    {
12563        let n = max_entries.clamp(1, 50);
12564        let script = format!(
12565            "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
12566            n
12567        );
12568        let output = Command::new("powershell")
12569            .args(["-NoProfile", "-Command", &script])
12570            .output()
12571            .map_err(|e| format!("Patch history query failed: {e}"))?;
12572        out.push_str("=== Recent HotFixes (KBs) ===\n");
12573        out.push_str(&String::from_utf8_lossy(&output.stdout));
12574    }
12575
12576    #[cfg(not(target_os = "windows"))]
12577    {
12578        out.push_str("Patch history is currently focused on Windows HotFixes.\n");
12579    }
12580
12581    Ok(out.trim_end().to_string())
12582}
12583
12584// ── ad_user ──────────────────────────────────────────────────────────────────
12585
12586fn inspect_ad_user(identity: &str) -> Result<String, String> {
12587    let mut out = String::from("Host inspection: ad_user\n\n");
12588    let ident = identity.trim();
12589    if ident.is_empty() {
12590        out.push_str("Status: No identity specified. Performing self-discovery...\n");
12591        #[cfg(target_os = "windows")]
12592        {
12593            let script = r#"
12594$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
12595"USER: " + $u.Name
12596"SID: " + $u.User.Value
12597"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
12598"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
12599"#;
12600            let output = Command::new("powershell")
12601                .args(["-NoProfile", "-Command", script])
12602                .output()
12603                .ok();
12604            if let Some(o) = output {
12605                out.push_str(&String::from_utf8_lossy(&o.stdout));
12606            }
12607        }
12608        return Ok(out);
12609    }
12610
12611    #[cfg(target_os = "windows")]
12612    {
12613        let escaped_ident = ps_escape_single_quoted(ident);
12614        let script = format!(
12615            r#"
12616try {{
12617    $u = Get-ADUser -Identity '{escaped_ident}' -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
12618    "NAME: " + $u.Name
12619    "SID: " + $u.SID
12620    "ENABLED: " + $u.Enabled
12621    "EXPIRED: " + $u.PasswordExpired
12622    "LOGON: " + $u.LastLogonDate
12623    "GROUPS: " + ($u.MemberOf -replace 'CN=([^,]+),.*', '$1' -join ", ")
12624}} catch {{
12625    # Fallback to net user if AD module is missing or fails
12626    $net = net user '{escaped_ident}' /domain 2>&1
12627    if ($LASTEXITCODE -eq 0) {{
12628        $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
12629    }} else {{
12630        "ERROR: " + $_.Exception.Message
12631    }}
12632}}"#
12633        );
12634
12635        let output = Command::new("powershell")
12636            .args(["-NoProfile", "-Command", &script])
12637            .output()
12638            .ok();
12639
12640        if let Some(o) = output {
12641            let stdout = String::from_utf8_lossy(&o.stdout);
12642            if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
12643                out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
12644            }
12645            out.push_str(&stdout);
12646        }
12647    }
12648
12649    #[cfg(not(target_os = "windows"))]
12650    {
12651        let _ = ident;
12652        out.push_str("(AD User lookup only available on Windows nodes)\n");
12653    }
12654
12655    Ok(out.trim_end().to_string())
12656}
12657
12658// ── dns_lookup ───────────────────────────────────────────────────────────────
12659
12660fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
12661    let mut out = String::from("Host inspection: dns_lookup\n\n");
12662    let target = name.trim();
12663    if target.is_empty() {
12664        return Err("Missing required target name for dns_lookup.".to_string());
12665    }
12666
12667    #[cfg(target_os = "windows")]
12668    {
12669        let escaped_target = ps_escape_single_quoted(target);
12670        let safe_record_type = validate_dns_record_type(record_type);
12671        let script = format!("Resolve-DnsName -Name '{escaped_target}' -Type {safe_record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
12672        let output = Command::new("powershell")
12673            .args(["-NoProfile", "-Command", &script])
12674            .output()
12675            .ok();
12676        if let Some(o) = output {
12677            let stdout = String::from_utf8_lossy(&o.stdout);
12678            if stdout.trim().is_empty() {
12679                let _ = writeln!(out, "No {record_type} records found for {target}.");
12680            } else {
12681                out.push_str(&stdout);
12682            }
12683        }
12684    }
12685
12686    #[cfg(not(target_os = "windows"))]
12687    {
12688        let output = Command::new("dig")
12689            .args([target, record_type, "+short"])
12690            .output()
12691            .ok();
12692        if let Some(o) = output {
12693            out.push_str(&String::from_utf8_lossy(&o.stdout));
12694        }
12695    }
12696
12697    Ok(out.trim_end().to_string())
12698}
12699
12700// ── hyperv ───────────────────────────────────────────────────────────────────
12701
12702#[cfg(target_os = "windows")]
12703fn ps_exec(script: &str) -> String {
12704    Command::new("powershell")
12705        .args(["-NoProfile", "-NonInteractive", "-Command", script])
12706        .output()
12707        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
12708        .unwrap_or_default()
12709}
12710
12711fn inspect_mdm_enrollment() -> Result<String, String> {
12712    #[cfg(target_os = "windows")]
12713    {
12714        let mut out = String::from("Host inspection: mdm_enrollment\n\n");
12715
12716        // ── dsregcmd /status — primary enrollment signal ──────────────────────
12717        out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
12718        let ps_dsreg = r#"
12719$raw = dsregcmd /status 2>$null
12720$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
12721            'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
12722foreach ($line in $raw) {
12723    $t = $line.Trim()
12724    foreach ($f in $fields) {
12725        if ($t -like "$f :*") {
12726            $val = ($t -split ':',2)[1].Trim()
12727            "$f`: $val"
12728        }
12729    }
12730}
12731"#;
12732        match run_powershell(ps_dsreg) {
12733            Ok(o) if !o.trim().is_empty() => {
12734                for line in o.lines() {
12735                    let l = line.trim();
12736                    if !l.is_empty() {
12737                        let _ = writeln!(out, "- {l}");
12738                    }
12739                }
12740            }
12741            Ok(_) => out.push_str(
12742                "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
12743            ),
12744            Err(e) => {
12745                let _ = writeln!(out, "- dsregcmd error: {e}");
12746            }
12747        }
12748
12749        // ── Registry enrollment accounts ──────────────────────────────────────
12750        out.push_str("\n=== Enrollment accounts (registry) ===\n");
12751        let ps_enroll = r#"
12752$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
12753if (Test-Path $base) {
12754    $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
12755    if ($accounts) {
12756        foreach ($acct in $accounts) {
12757            $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
12758            $upn    = if ($p.UPN)                { $p.UPN }                else { '(none)' }
12759            $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
12760            $type   = switch ($p.EnrollmentType) {
12761                6  { 'MDM' }
12762                13 { 'MAM' }
12763                default { "Type=$($p.EnrollmentType)" }
12764            }
12765            $state  = switch ($p.EnrollmentState) {
12766                1  { 'Enrolled' }
12767                2  { 'InProgress' }
12768                6  { 'Unenrolled' }
12769                default { "State=$($p.EnrollmentState)" }
12770            }
12771            "Account: $upn | $type | $state | $server"
12772        }
12773    } else { "No enrollment accounts found under $base" }
12774} else { "Enrollment registry key not found — device is not MDM-enrolled" }
12775"#;
12776        match run_powershell(ps_enroll) {
12777            Ok(o) => {
12778                for line in o.lines() {
12779                    let l = line.trim();
12780                    if !l.is_empty() {
12781                        let _ = writeln!(out, "- {l}");
12782                    }
12783                }
12784            }
12785            Err(e) => {
12786                let _ = writeln!(out, "- Registry read error: {e}");
12787            }
12788        }
12789
12790        // ── MDM service health ────────────────────────────────────────────────
12791        out.push_str("\n=== MDM services ===\n");
12792        let ps_svc = r#"
12793$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
12794foreach ($n in $names) {
12795    $s = Get-Service -Name $n -ErrorAction SilentlyContinue
12796    if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
12797}
12798"#;
12799        match run_powershell(ps_svc) {
12800            Ok(o) if !o.trim().is_empty() => {
12801                for line in o.lines() {
12802                    let l = line.trim();
12803                    if !l.is_empty() {
12804                        let _ = writeln!(out, "- {l}");
12805                    }
12806                }
12807            }
12808            Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
12809            Err(e) => { let _ = writeln!(out, "- Service query error: {e}"); }
12810        }
12811
12812        // ── Recent MDM / Intune events ────────────────────────────────────────
12813        out.push_str("\n=== Recent MDM events (last 24h) ===\n");
12814        let ps_evt = r#"
12815$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
12816          'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
12817$cutoff = (Get-Date).AddHours(-24)
12818$found = $false
12819foreach ($log in $logs) {
12820    $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
12821            Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
12822    foreach ($e in $evts) {
12823        $found = $true
12824        $ts = $e.TimeCreated.ToString('HH:mm')
12825        $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
12826        "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
12827    }
12828}
12829if (-not $found) { "No MDM warning/error events in the last 24 hours" }
12830"#;
12831        match run_powershell(ps_evt) {
12832            Ok(o) => {
12833                for line in o.lines() {
12834                    let l = line.trim();
12835                    if !l.is_empty() {
12836                        let _ = writeln!(out, "- {l}");
12837                    }
12838                }
12839            }
12840            Err(e) => {
12841                let _ = writeln!(out, "- Event log read error: {e}");
12842            }
12843        }
12844
12845        // ── Findings ──────────────────────────────────────────────────────────
12846        out.push_str("\n=== Findings ===\n");
12847        let body = out.clone();
12848        let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
12849        let intune_running = body.contains("IntuneManagementExtension: Running");
12850        let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
12851
12852        if !enrolled {
12853            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");
12854        } else {
12855            out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
12856            if !intune_running {
12857                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");
12858            }
12859        }
12860        if has_errors {
12861            out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
12862        }
12863        if !enrolled && !has_errors {
12864            out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
12865        }
12866
12867        Ok(out)
12868    }
12869
12870    #[cfg(not(target_os = "windows"))]
12871    {
12872        Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
12873    }
12874}
12875
12876fn inspect_hyperv() -> Result<String, String> {
12877    #[cfg(target_os = "windows")]
12878    {
12879        let mut findings: Vec<String> = Vec::with_capacity(4);
12880        let mut out = String::with_capacity(2048);
12881
12882        // --- Hyper-V role / VMMS service state ---
12883        let ps_role = r#"
12884$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
12885$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
12886$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
12887$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
12888"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
12889    $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
12890    $(if ($feature) { $feature.State } else { "Unknown" }),
12891    $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
12892    $(if ($ram) { $ram } else { "0" })
12893"#;
12894        let role_out = ps_exec(ps_role);
12895        out.push_str("=== Hyper-V role state ===\n");
12896
12897        let mut vmms_running = false;
12898        let mut host_ram_bytes: u64 = 0;
12899
12900        if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
12901            let kv: std::collections::HashMap<&str, &str> = line
12902                .split('|')
12903                .filter_map(|p| {
12904                    let mut it = p.splitn(2, ':');
12905                    Some((it.next()?, it.next()?))
12906                })
12907                .collect();
12908            let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
12909            let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
12910            let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
12911            host_ram_bytes = kv
12912                .get("HostRAMBytes")
12913                .and_then(|v| v.parse().ok())
12914                .unwrap_or(0);
12915
12916            let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
12917            vmms_running = vmms_status.starts_with("Running");
12918
12919            let _ = writeln!(out, "- Host: {host_name}");
12920            let _ = writeln!(
12921                out,
12922                "- Hyper-V feature: {}",
12923                if hyperv_installed {
12924                    "Enabled"
12925                } else {
12926                    "Not installed"
12927                }
12928            );
12929            let _ = writeln!(out, "- VMMS service: {vmms_status}");
12930            if host_ram_bytes > 0 {
12931                let _ = writeln!(
12932                    out,
12933                    "- Host physical RAM: {} GB",
12934                    host_ram_bytes / 1_073_741_824
12935                );
12936            }
12937
12938            if !hyperv_installed {
12939                findings.push(
12940                    "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
12941                );
12942            } else if !vmms_running {
12943                findings.push(
12944                    "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
12945                );
12946            }
12947        } else {
12948            out.push_str("- Could not determine Hyper-V role state\n");
12949            findings.push("Hyper-V does not appear to be installed on this machine.".into());
12950        }
12951
12952        // --- Virtual machines ---
12953        out.push_str("\n=== Virtual machines ===\n");
12954        if vmms_running {
12955            let ps_vms = r#"
12956Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
12957    $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
12958    "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
12959        $_.Name, $_.State, $_.CPUUsage, $ram_gb,
12960        $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
12961        $_.Status, $_.Generation
12962}
12963"#;
12964            let vms_out = ps_exec(ps_vms);
12965            let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
12966
12967            if vm_lines.is_empty() {
12968                out.push_str("- No virtual machines found on this host\n");
12969            } else {
12970                let mut total_ram_bytes: u64 = 0;
12971                let mut saved_vms: Vec<String> = Vec::new();
12972                for line in &vm_lines {
12973                    let kv: std::collections::HashMap<&str, &str> = line
12974                        .split('|')
12975                        .filter_map(|p| {
12976                            let mut it = p.splitn(2, ':');
12977                            Some((it.next()?, it.next()?))
12978                        })
12979                        .collect();
12980                    let name = kv.get("VM").copied().unwrap_or("Unknown");
12981                    let state = kv.get("State").copied().unwrap_or("Unknown");
12982                    let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
12983                    let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
12984                    let uptime = kv.get("Uptime").copied().unwrap_or("Off");
12985                    let status = kv.get("Status").copied().unwrap_or("");
12986                    let gen = kv.get("Generation").copied().unwrap_or("?");
12987
12988                    if let Ok(r) = ram.parse::<f64>() {
12989                        total_ram_bytes += (r * 1_073_741_824.0) as u64;
12990                    }
12991                    if state.eq_ignore_ascii_case("Saved") {
12992                        saved_vms.push(name.to_string());
12993                    }
12994
12995                    let _ = writeln!(out,
12996                        "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}"
12997                    );
12998                    if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
12999                        let _ = writeln!(out, "  Status: {status}");
13000                    }
13001                }
13002
13003                let _ = write!(out, "\n- Total VMs: {}\n", vm_lines.len());
13004                if total_ram_bytes > 0 && host_ram_bytes > 0 {
13005                    let pct = (total_ram_bytes * 100) / host_ram_bytes;
13006                    let _ = writeln!(
13007                        out,
13008                        "- Total VM RAM assigned: {} GB ({pct}% of host RAM)",
13009                        total_ram_bytes / 1_073_741_824
13010                    );
13011                    if pct > 90 {
13012                        findings.push(format!(
13013                            "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
13014                        ));
13015                    }
13016                }
13017                if !saved_vms.is_empty() {
13018                    findings.push(format!(
13019                        "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
13020                        saved_vms.join(", ")
13021                    ));
13022                }
13023            }
13024        } else {
13025            out.push_str("- VMMS not running — cannot enumerate VMs\n");
13026        }
13027
13028        // --- VM network switches ---
13029        out.push_str("\n=== VM network switches ===\n");
13030        if vmms_running {
13031            let ps_switches = r#"
13032Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
13033    "Switch:{0}|Type:{1}|Adapter:{2}" -f `
13034        $_.Name, $_.SwitchType,
13035        $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
13036}
13037"#;
13038            let sw_out = ps_exec(ps_switches);
13039            let switch_lines: Vec<&str> = sw_out
13040                .lines()
13041                .filter(|l| l.starts_with("Switch:"))
13042                .collect();
13043
13044            if switch_lines.is_empty() {
13045                out.push_str("- No VM switches configured\n");
13046            } else {
13047                for line in &switch_lines {
13048                    let kv: std::collections::HashMap<&str, &str> = line
13049                        .split('|')
13050                        .filter_map(|p| {
13051                            let mut it = p.splitn(2, ':');
13052                            Some((it.next()?, it.next()?))
13053                        })
13054                        .collect();
13055                    let name = kv.get("Switch").copied().unwrap_or("Unknown");
13056                    let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
13057                    let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
13058                    let _ = writeln!(out, "- {name} | Type: {sw_type} | NIC: {adapter}");
13059                }
13060            }
13061        } else {
13062            out.push_str("- VMMS not running — cannot enumerate switches\n");
13063        }
13064
13065        // --- VM checkpoints ---
13066        out.push_str("\n=== VM checkpoints ===\n");
13067        if vmms_running {
13068            let ps_checkpoints = r#"
13069$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
13070if ($all) {
13071    $all | ForEach-Object {
13072        "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
13073            $_.Name, $_.VMName,
13074            $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
13075            $_.SnapshotType
13076    }
13077} else {
13078    "NONE"
13079}
13080"#;
13081            let cp_out = ps_exec(ps_checkpoints);
13082            if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
13083                out.push_str("- No checkpoints found\n");
13084            } else {
13085                let cp_lines: Vec<&str> = cp_out
13086                    .lines()
13087                    .filter(|l| l.starts_with("Checkpoint:"))
13088                    .collect();
13089                let mut per_vm: std::collections::HashMap<&str, usize> =
13090                    std::collections::HashMap::new();
13091                for line in &cp_lines {
13092                    let kv: std::collections::HashMap<&str, &str> = line
13093                        .split('|')
13094                        .filter_map(|p| {
13095                            let mut it = p.splitn(2, ':');
13096                            Some((it.next()?, it.next()?))
13097                        })
13098                        .collect();
13099                    let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
13100                    let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
13101                    let created = kv.get("Created").copied().unwrap_or("");
13102                    let cp_type = kv.get("Type").copied().unwrap_or("");
13103                    let _ = writeln!(
13104                        out,
13105                        "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}"
13106                    );
13107                    *per_vm.entry(vm_name).or_insert(0) += 1;
13108                }
13109                for (vm, count) in &per_vm {
13110                    if *count >= 3 {
13111                        findings.push(format!(
13112                            "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
13113                        ));
13114                    }
13115                }
13116            }
13117        } else {
13118            out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
13119        }
13120
13121        let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
13122        if findings.is_empty() {
13123            result.push_str("- No Hyper-V health issues detected.\n");
13124        } else {
13125            for f in &findings {
13126                let _ = writeln!(result, "- Finding: {f}");
13127            }
13128        }
13129        result.push('\n');
13130        result.push_str(&out);
13131        Ok(result.trim_end().to_string())
13132    }
13133
13134    #[cfg(not(target_os = "windows"))]
13135    Ok(
13136        "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
13137            .into(),
13138    )
13139}
13140
13141// ── ip_config ────────────────────────────────────────────────────────────────
13142
13143fn inspect_ip_config() -> Result<String, String> {
13144    let mut out = String::from("Host inspection: ip_config\n\n");
13145
13146    #[cfg(target_os = "windows")]
13147    {
13148        let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
13149            $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
13150            '\\n  Status: ' + $_.NetAdapter.Status + \
13151            '\\n  Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
13152            '\\n  DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
13153            '\\n  DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
13154            '\\n  IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
13155            '\\n  DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
13156        }";
13157        let output = Command::new("powershell")
13158            .args(["-NoProfile", "-Command", script])
13159            .output()
13160            .ok();
13161        if let Some(o) = output {
13162            out.push_str(&String::from_utf8_lossy(&o.stdout));
13163        }
13164    }
13165
13166    #[cfg(not(target_os = "windows"))]
13167    {
13168        let output = Command::new("ip").args(["addr", "show"]).output().ok();
13169        if let Some(o) = output {
13170            out.push_str(&String::from_utf8_lossy(&o.stdout));
13171        }
13172    }
13173
13174    Ok(out.trim_end().to_string())
13175}
13176
13177// ── event_query ──────────────────────────────────────────────────────────────
13178
13179fn inspect_event_query(
13180    event_id: Option<u32>,
13181    log_name: Option<&str>,
13182    source: Option<&str>,
13183    hours: u32,
13184    level: Option<&str>,
13185    max_entries: usize,
13186) -> Result<String, String> {
13187    #[cfg(target_os = "windows")]
13188    {
13189        let mut findings: Vec<String> = Vec::with_capacity(4);
13190
13191        // Build the PowerShell filter hash
13192        let log = log_name.unwrap_or("*");
13193        let cap = max_entries.min(50);
13194
13195        // Level mapping: Error=2, Warning=3, Information=4
13196        let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
13197            Some("error") | Some("errors") => Some(2u8),
13198            Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
13199            Some("information") | Some("info") => Some(4u8),
13200            _ => None,
13201        };
13202
13203        // Build filter hashtable entries
13204        let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
13205        if log != "*" {
13206            let escaped_log = ps_escape_single_quoted(log);
13207            filter_parts.push(format!("LogName = '{escaped_log}'"));
13208        }
13209        if let Some(id) = event_id {
13210            filter_parts.push(format!("Id = {id}"));
13211        }
13212        if let Some(src) = source {
13213            let escaped_src = ps_escape_single_quoted(src);
13214            filter_parts.push(format!("ProviderName = '{escaped_src}'"));
13215        }
13216        if let Some(lvl) = level_filter {
13217            filter_parts.push(format!("Level = {lvl}"));
13218        }
13219
13220        let filter_ht = filter_parts.join("; ");
13221
13222        let ps = format!(
13223            r#"
13224$filter = @{{ {filter_ht} }}
13225try {{
13226    $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
13227        Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
13228            @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
13229    if ($events) {{
13230        $events | ForEach-Object {{
13231            "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
13232                $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
13233                $_.Id, $_.LevelDisplayName, $_.ProviderName,
13234                ($_.Msg -replace '\|','/')
13235        }}
13236    }} else {{
13237        "NONE"
13238    }}
13239}} catch {{
13240    "ERROR:$($_.Exception.Message)"
13241}}
13242"#
13243        );
13244
13245        let raw = ps_exec(&ps);
13246        let lines: Vec<&str> = raw.lines().collect();
13247
13248        // Build query description for header
13249        let mut query_desc = format!("last {hours}h");
13250        if let Some(id) = event_id {
13251            let _ = write!(query_desc, ", Event ID {id}");
13252        }
13253        if let Some(src) = source {
13254            let _ = write!(query_desc, ", source '{src}'");
13255        }
13256        if log != "*" {
13257            let _ = write!(query_desc, ", log '{log}'");
13258        }
13259        if let Some(l) = level {
13260            let _ = write!(query_desc, ", level '{l}'");
13261        }
13262
13263        let mut out = format!("=== Event query: {query_desc} ===\n");
13264
13265        if lines
13266            .iter()
13267            .any(|l| l.trim() == "NONE" || l.trim().is_empty())
13268        {
13269            out.push_str("- No matching events found.\n");
13270        } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
13271            let msg = err_line.trim_start_matches("ERROR:").trim();
13272            if is_event_query_no_results_message(msg) {
13273                out.push_str("- No matching events found.\n");
13274            } else {
13275                let _ = writeln!(out, "- Query error: {msg}");
13276                findings.push(format!("Event query failed: {msg}"));
13277            }
13278        } else {
13279            let event_lines: Vec<&str> = lines
13280                .iter()
13281                .filter(|l| l.starts_with("TIME:"))
13282                .copied()
13283                .collect();
13284            if event_lines.is_empty() {
13285                out.push_str("- No matching events found.\n");
13286            } else {
13287                // Tally by level for findings
13288                let mut error_count = 0usize;
13289                let mut warning_count = 0usize;
13290
13291                for line in &event_lines {
13292                    let kv: std::collections::HashMap<&str, &str> = line
13293                        .split('|')
13294                        .filter_map(|p| {
13295                            let mut it = p.splitn(2, ':');
13296                            Some((it.next()?, it.next()?))
13297                        })
13298                        .collect();
13299                    let time = kv.get("TIME").copied().unwrap_or("?");
13300                    let id = kv.get("ID").copied().unwrap_or("?");
13301                    let lvl = kv.get("LEVEL").copied().unwrap_or("?");
13302                    let src = kv.get("SOURCE").copied().unwrap_or("?");
13303                    let msg = kv.get("MSG").copied().unwrap_or("").trim();
13304
13305                    // Truncate long messages
13306                    let msg_display = if msg.len() > 120 {
13307                        format!("{}…", safe_head(msg, 120))
13308                    } else {
13309                        msg.to_string()
13310                    };
13311
13312                    let _ = write!(out, "- [{time}] ID {id} | {lvl} | {src}\n  {msg_display}\n");
13313
13314                    if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
13315                        error_count += 1;
13316                    } else if lvl.eq_ignore_ascii_case("warning") {
13317                        warning_count += 1;
13318                    }
13319                }
13320
13321                let _ = write!(out, "\n- Total shown: {} event(s)\n", event_lines.len());
13322
13323                if error_count > 0 {
13324                    findings.push(format!(
13325                        "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
13326                    ));
13327                }
13328                if warning_count > 5 {
13329                    findings.push(format!(
13330                        "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
13331                    ));
13332                }
13333            }
13334        }
13335
13336        let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
13337        if findings.is_empty() {
13338            result.push_str("- No actionable findings from this event query.\n");
13339        } else {
13340            for f in &findings {
13341                let _ = writeln!(result, "- Finding: {f}");
13342            }
13343        }
13344        result.push('\n');
13345        result.push_str(&out);
13346        Ok(result.trim_end().to_string())
13347    }
13348
13349    #[cfg(not(target_os = "windows"))]
13350    {
13351        let _ = (event_id, log_name, source, hours, level, max_entries);
13352        Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
13353    }
13354}
13355
13356// ── app_crashes ───────────────────────────────────────────────────────────────
13357
13358fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
13359    let n = max_entries.clamp(5, 50);
13360    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
13361    let mut findings: Vec<String> = Vec::with_capacity(4);
13362    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
13363    let mut sections = String::with_capacity(2048);
13364
13365    #[cfg(target_os = "windows")]
13366    {
13367        let proc_filter_ps = match process_filter {
13368            Some(proc) => format!(
13369                "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
13370                proc.replace('\'', "''")
13371            ),
13372            None => String::new(),
13373        };
13374
13375        let ps = format!(
13376            r#"
13377$results = @()
13378try {{
13379    $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
13380    if ($events) {{
13381        foreach ($e in $events) {{
13382            $msg  = $e.Message
13383            $app  = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
13384            $ver  = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
13385            $mod  = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
13386            $exc  = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
13387            $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
13388            $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
13389        }}
13390        $results
13391    }} else {{ 'NONE' }}
13392}} catch {{ 'ERROR:' + $_.Exception.Message }}
13393"#
13394        );
13395
13396        let raw = ps_exec(&ps);
13397        let text = raw.trim();
13398
13399        // WER archive count (non-blocking best-effort)
13400        let wer_ps = r#"
13401$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
13402$count = 0
13403if (Test-Path $wer) {
13404    $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
13405}
13406$count
13407"#;
13408        let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
13409
13410        if text == "NONE" {
13411            sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
13412        } else if text.starts_with("ERROR:") {
13413            let msg = text.trim_start_matches("ERROR:").trim();
13414            let _ = write!(
13415                sections,
13416                "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
13417            );
13418        } else {
13419            let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
13420            let crash_count = events
13421                .iter()
13422                .filter(|l| l.split('|').nth(1) == Some("CRASH"))
13423                .count();
13424            let hang_count = events
13425                .iter()
13426                .filter(|l| l.split('|').nth(1) == Some("HANG"))
13427                .count();
13428
13429            // Tally crashes per app
13430            let mut app_counts: std::collections::HashMap<String, usize> =
13431                std::collections::HashMap::new();
13432            for line in &events {
13433                let mut it = line.splitn(6, '|');
13434                if let (Some(_), Some(_), Some(app)) = (it.next(), it.next(), it.next()) {
13435                    *app_counts.entry(app.to_string()).or_insert(0) += 1;
13436                }
13437            }
13438
13439            if crash_count > 0 {
13440                findings.push(format!(
13441                    "{crash_count} application crash event(s) — review below for faulting app and exception code."
13442                ));
13443            }
13444            if hang_count > 0 {
13445                findings.push(format!(
13446                    "{hang_count} application hang event(s) — process stopped responding."
13447                ));
13448            }
13449            if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
13450                if count > 1 {
13451                    findings.push(format!(
13452                        "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
13453                    ));
13454                }
13455            }
13456            if wer_count > 10 {
13457                findings.push(format!(
13458                    "{wer_count} WER reports archived — elevated crash history on this machine."
13459                ));
13460            }
13461
13462            let filter_note = match process_filter {
13463                Some(p) => format!(" (filtered: {p})"),
13464                None => String::new(),
13465            };
13466            let _ = writeln!(
13467                sections,
13468                "=== Application crashes and hangs{filter_note} ==="
13469            );
13470
13471            for line in &events {
13472                let mut it = line.splitn(6, '|');
13473                if let (Some(time), Some(kind), Some(app), Some(ver), Some(module), Some(exc)) = (
13474                    it.next(),
13475                    it.next(),
13476                    it.next(),
13477                    it.next(),
13478                    it.next(),
13479                    it.next(),
13480                ) {
13481                    let ver_note = if !ver.is_empty() {
13482                        format!(" v{ver}")
13483                    } else {
13484                        String::new()
13485                    };
13486                    let _ = writeln!(sections, "  [{time}] {kind}: {app}{ver_note}");
13487                    if !module.is_empty() && module != "?" {
13488                        let exc_note = if !exc.is_empty() {
13489                            format!(" (exc {exc})")
13490                        } else {
13491                            String::new()
13492                        };
13493                        let _ = writeln!(sections, "    faulting module: {module}{exc_note}");
13494                    } else if !exc.is_empty() {
13495                        let _ = writeln!(sections, "    exception: {exc}");
13496                    }
13497                }
13498            }
13499            let _ = write!(
13500                sections,
13501                "\n  Total: {crash_count} crash(es), {hang_count} hang(s)\n"
13502            );
13503
13504            if wer_count > 0 {
13505                let _ = write!(sections,
13506                    "\n=== Windows Error Reporting ===\n  WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
13507                );
13508            }
13509        }
13510    }
13511
13512    #[cfg(not(target_os = "windows"))]
13513    {
13514        let _ = (process_filter, n);
13515        sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
13516    }
13517
13518    let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
13519    if findings.is_empty() {
13520        result.push_str("- No actionable findings.\n");
13521    } else {
13522        for f in &findings {
13523            let _ = writeln!(result, "- Finding: {f}");
13524        }
13525    }
13526    result.push('\n');
13527    result.push_str(&sections);
13528    Ok(result.trim_end().to_string())
13529}
13530
13531#[cfg(target_os = "windows")]
13532fn gpu_voltage_telemetry_note() -> String {
13533    let output = Command::new("nvidia-smi")
13534        .args(["--help-query-gpu"])
13535        .output();
13536
13537    match output {
13538        Ok(o) => {
13539            let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
13540            if text.contains("\"voltage\"") || text.contains("voltage.") {
13541                "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
13542            } else {
13543                "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()
13544            }
13545        }
13546        Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
13547    }
13548}
13549
13550#[cfg(target_os = "windows")]
13551fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
13552    if raw == 0 {
13553        return None;
13554    }
13555    if raw & 0x80 != 0 {
13556        let tenths = raw & 0x7f;
13557        return Some(format!(
13558            "{:.1} V (firmware-reported WMI current voltage)",
13559            tenths as f64 / 10.0
13560        ));
13561    }
13562
13563    let legacy = match raw {
13564        1 => Some("5.0 V"),
13565        2 => Some("3.3 V"),
13566        4 => Some("2.9 V"),
13567        _ => None,
13568    }?;
13569    Some(format!(
13570        "{} (legacy WMI voltage capability flag, not live telemetry)",
13571        legacy
13572    ))
13573}
13574
13575async fn inspect_overclocker() -> Result<String, String> {
13576    let mut out = String::from("Host inspection: overclocker\n\n");
13577
13578    #[cfg(target_os = "windows")]
13579    {
13580        out.push_str(
13581            "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
13582        );
13583
13584        // 1. NVIDIA Census
13585        let nvidia = Command::new("nvidia-smi")
13586            .args([
13587                "--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",
13588                "--format=csv,noheader,nounits",
13589            ])
13590            .output();
13591
13592        if let Ok(o) = nvidia {
13593            let stdout = String::from_utf8_lossy(&o.stdout);
13594            if !stdout.trim().is_empty() {
13595                out.push_str("=== GPU SENSE (NVIDIA) ===\n");
13596                let mut parts = Vec::with_capacity(16);
13597                parts.extend(stdout.trim().split(',').map(|s| s.trim()));
13598                if parts.len() >= 10 {
13599                    let _ = writeln!(out, "- Model:      {}", parts[0]);
13600                    let _ = writeln!(out, "- Graphics:   {} MHz", parts[1]);
13601                    let _ = writeln!(out, "- Memory:     {} MHz", parts[2]);
13602                    let _ = writeln!(out, "- Fan Speed:  {}%", parts[3]);
13603                    let _ = writeln!(out, "- Power Draw: {} W", parts[4]);
13604                    if !parts[6].eq_ignore_ascii_case("[N/A]") {
13605                        let _ = writeln!(out, "- Power Avg:  {} W", parts[6]);
13606                    }
13607                    if !parts[7].eq_ignore_ascii_case("[N/A]") {
13608                        let _ = writeln!(out, "- Power Inst: {} W", parts[7]);
13609                    }
13610                    if !parts[8].eq_ignore_ascii_case("[N/A]") {
13611                        let _ = writeln!(out, "- Power Cap:  {} W requested", parts[8]);
13612                    }
13613                    if !parts[9].eq_ignore_ascii_case("[N/A]") {
13614                        let _ = writeln!(out, "- Power Enf:  {} W enforced", parts[9]);
13615                    }
13616                    let _ = writeln!(out, "- Temperature: {}°C", parts[5]);
13617
13618                    if parts.len() > 10 {
13619                        let throttle_hex = parts[10];
13620                        let reasons = decode_nvidia_throttle_reasons(throttle_hex);
13621                        if !reasons.is_empty() {
13622                            let _ = writeln!(out, "- Throttling:  YES [Reason: {}]", reasons);
13623                        } else {
13624                            out.push_str("- Throttling:  None (Performance State: Max)\n");
13625                        }
13626                    }
13627                }
13628                out.push('\n');
13629            }
13630        }
13631
13632        out.push_str("=== VOLTAGE TELEMETRY ===\n");
13633        let _ = write!(out, "- GPU Voltage:  {}\n\n", gpu_voltage_telemetry_note());
13634
13635        // 1b. Session Trends (RAM-only historians)
13636        let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
13637        let history = gpu_state.history.read().unwrap();
13638        if history.len() >= 2 {
13639            out.push_str("=== SILICON TRENDS (Session) ===\n");
13640            let first = history.front().unwrap();
13641            let last = history.back().unwrap();
13642
13643            let temp_diff = last.temperature as i32 - first.temperature as i32;
13644            let clock_diff = last.core_clock as i32 - first.core_clock as i32;
13645
13646            let temp_trend = if temp_diff > 1 {
13647                "Rising"
13648            } else if temp_diff < -1 {
13649                "Falling"
13650            } else {
13651                "Stable"
13652            };
13653            let clock_trend = if clock_diff > 10 {
13654                "Increasing"
13655            } else if clock_diff < -10 {
13656                "Decreasing"
13657            } else {
13658                "Stable"
13659            };
13660
13661            let _ = writeln!(
13662                out,
13663                "- Temperature: {} ({}°C anomaly)",
13664                temp_trend, temp_diff
13665            );
13666            let _ = writeln!(
13667                out,
13668                "- Core Clock:  {} ({} MHz delta)",
13669                clock_trend, clock_diff
13670            );
13671            out.push('\n');
13672        }
13673
13674        // 2. CPU Time-Series (2 samples)
13675        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))\" }";
13676        let cpu_stats = Command::new("powershell")
13677            .args(["-NoProfile", "-Command", ps_cmd])
13678            .output();
13679
13680        if let Ok(o) = cpu_stats {
13681            let stdout = String::from_utf8_lossy(&o.stdout);
13682            if !stdout.trim().is_empty() {
13683                out.push_str("=== SILICON CORE (CPU) ===\n");
13684                for line in stdout.lines() {
13685                    if let Some((path, val)) = line.split_once(':') {
13686                        let path_lower = path.to_lowercase();
13687                        if path_lower.contains("processor frequency") {
13688                            let _ = writeln!(out, "- Current Freq:  {} MHz (2s Avg)", val);
13689                        } else if path_lower.contains("% of maximum frequency") {
13690                            let _ = writeln!(out, "- Throttling:     {}% of Max Capacity", val);
13691                            let throttle_num = val.parse::<f64>().unwrap_or(100.0);
13692                            if throttle_num < 95.0 {
13693                                out.push_str(
13694                                    "  [WARNING] Active downclocking or power-saving detected.\n",
13695                                );
13696                            }
13697                        }
13698                    }
13699                }
13700            }
13701        }
13702
13703        // 2b. CPU Thermal Fallback
13704        let thermal = Command::new("powershell")
13705            .args([
13706                "-NoProfile",
13707                "-Command",
13708                "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
13709            ])
13710            .output();
13711        if let Ok(o) = thermal {
13712            let stdout = String::from_utf8_lossy(&o.stdout);
13713            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13714                let temp = if v.is_array() {
13715                    v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13716                } else {
13717                    v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
13718                };
13719                if temp > 1.0 {
13720                    let _ = writeln!(out, "- CPU Package:   {}°C (ACPI Zone)", temp);
13721                }
13722            }
13723        }
13724
13725        // 3. WMI Static Fallback/Context
13726        let wmi = Command::new("powershell")
13727            .args([
13728                "-NoProfile",
13729                "-Command",
13730                "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
13731            ])
13732            .output();
13733
13734        if let Ok(o) = wmi {
13735            let stdout = String::from_utf8_lossy(&o.stdout);
13736            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
13737                out.push_str("\n=== HARDWARE DNA ===\n");
13738                let _ = writeln!(
13739                    out,
13740                    "- Rated Max:     {} MHz",
13741                    v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
13742                );
13743                match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
13744                    Some(raw) => {
13745                        if let Some(decoded) = decode_wmi_processor_voltage(raw) {
13746                            let _ = writeln!(out, "- CPU Voltage:   {}", decoded);
13747                        } else {
13748                            out.push_str(
13749                                "- CPU Voltage:   Unavailable or non-telemetry WMI value on this firmware path\n",
13750                            );
13751                        }
13752                    }
13753                    None => out.push_str("- CPU Voltage:   Unavailable on this WMI path\n"),
13754                }
13755            }
13756        }
13757    }
13758
13759    #[cfg(not(target_os = "windows"))]
13760    {
13761        out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
13762    }
13763
13764    Ok(out.trim_end().to_string())
13765}
13766
13767/// Decodes the NVIDIA Clocks Throttle Reasons HEX bitmask.
13768#[cfg(target_os = "windows")]
13769fn decode_nvidia_throttle_reasons(hex: &str) -> String {
13770    let hex = hex.trim().trim_start_matches("0x");
13771    let val = match u64::from_str_radix(hex, 16) {
13772        Ok(v) => v,
13773        Err(_) => return String::new(),
13774    };
13775
13776    if val == 0 {
13777        return String::new();
13778    }
13779
13780    let mut reasons = Vec::with_capacity(9);
13781    if val & 0x01 != 0 {
13782        reasons.push("GPU Idle");
13783    }
13784    if val & 0x02 != 0 {
13785        reasons.push("Applications Clocks Setting");
13786    }
13787    if val & 0x04 != 0 {
13788        reasons.push("SW Power Cap (PL1/PL2)");
13789    }
13790    if val & 0x08 != 0 {
13791        reasons.push("HW Slowdown (Thermal/Power)");
13792    }
13793    if val & 0x10 != 0 {
13794        reasons.push("Sync Boost");
13795    }
13796    if val & 0x20 != 0 {
13797        reasons.push("SW Thermal Slowdown");
13798    }
13799    if val & 0x40 != 0 {
13800        reasons.push("HW Thermal Slowdown");
13801    }
13802    if val & 0x80 != 0 {
13803        reasons.push("HW Power Brake Slowdown");
13804    }
13805    if val & 0x100 != 0 {
13806        reasons.push("Display Clock Setting");
13807    }
13808
13809    reasons.join(", ")
13810}
13811
13812// ── PowerShell helper (used by camera / sign_in / search_index) ───────────────
13813
13814#[cfg(windows)]
13815fn run_powershell(script: &str) -> Result<String, String> {
13816    use std::process::Command;
13817    let out = Command::new("powershell")
13818        .args(["-NoProfile", "-NonInteractive", "-Command", script])
13819        .output()
13820        .map_err(|e| format!("powershell launch failed: {e}"))?;
13821    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
13822}
13823
13824// ── inspect_camera ────────────────────────────────────────────────────────────
13825
13826#[cfg(windows)]
13827fn inspect_camera(max_entries: usize) -> Result<String, String> {
13828    let mut out = String::from("=== Camera devices ===\n");
13829
13830    // PnP camera devices
13831    let ps_devices = r#"
13832Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
13833ForEach-Object {
13834    $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
13835    "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
13836}
13837"#;
13838    match run_powershell(ps_devices) {
13839        Ok(o) if !o.trim().is_empty() => {
13840            for line in o.lines().take(max_entries) {
13841                let l = line.trim();
13842                if !l.is_empty() {
13843                    let _ = writeln!(out, "- {l}");
13844                }
13845            }
13846        }
13847        _ => out.push_str("- No camera devices found via PnP\n"),
13848    }
13849
13850    // Windows privacy / capability gate
13851    out.push_str("\n=== Windows camera privacy ===\n");
13852    let ps_privacy = r#"
13853$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
13854$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
13855"Global: $global"
13856$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
13857    Where-Object { $_.PSChildName -ne 'NonPackaged' } |
13858    ForEach-Object {
13859        $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
13860        if ($v) { "  $($_.PSChildName): $v" }
13861    }
13862$apps
13863"#;
13864    match run_powershell(ps_privacy) {
13865        Ok(o) if !o.trim().is_empty() => {
13866            for line in o.lines().take(max_entries) {
13867                let l = line.trim_end();
13868                if !l.is_empty() {
13869                    let _ = writeln!(out, "{l}");
13870                }
13871            }
13872        }
13873        _ => out.push_str("- Could not read camera privacy registry\n"),
13874    }
13875
13876    // Windows Hello camera (IR / face auth)
13877    out.push_str("\n=== Biometric / Hello camera ===\n");
13878    let ps_bio = r#"
13879Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
13880ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
13881"#;
13882    match run_powershell(ps_bio) {
13883        Ok(o) if !o.trim().is_empty() => {
13884            for line in o.lines().take(max_entries) {
13885                let l = line.trim();
13886                if !l.is_empty() {
13887                    let _ = writeln!(out, "- {l}");
13888                }
13889            }
13890        }
13891        _ => out.push_str("- No biometric devices found\n"),
13892    }
13893
13894    // Findings
13895    let mut findings: Vec<String> = Vec::with_capacity(4);
13896    if out.contains("Status: Error") || out.contains("Status: Unknown") {
13897        findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
13898    }
13899    if out.contains("Global: Deny") {
13900        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());
13901    }
13902
13903    let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
13904    if findings.is_empty() {
13905        result.push_str("- No obvious camera or privacy gate issue detected.\n");
13906        result.push_str("  If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
13907    } else {
13908        for f in &findings {
13909            let _ = writeln!(result, "- Finding: {f}");
13910        }
13911    }
13912    result.push('\n');
13913    result.push_str(&out);
13914    Ok(result)
13915}
13916
13917#[cfg(not(windows))]
13918fn inspect_camera(_max_entries: usize) -> Result<String, String> {
13919    Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
13920}
13921
13922// ── inspect_sign_in ───────────────────────────────────────────────────────────
13923
13924#[cfg(windows)]
13925fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
13926    let mut out = String::from("=== Windows Hello and sign-in state ===\n");
13927
13928    // Windows Hello PIN and face/fingerprint readiness
13929    let ps_hello = r#"
13930$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
13931$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
13932$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
13933"PIN-style logon path: $helloKey"
13934"WbioSrvc start type: $faceConfigured"
13935"FingerPrint key present: $pinConfigured"
13936"#;
13937    match run_powershell(ps_hello) {
13938        Ok(o) => {
13939            for line in o.lines().take(max_entries) {
13940                let l = line.trim();
13941                if !l.is_empty() {
13942                    let _ = writeln!(out, "- {l}");
13943                }
13944            }
13945        }
13946        Err(e) => {
13947            let _ = writeln!(out, "- Hello query error: {e}");
13948        }
13949    }
13950
13951    // Biometric service state
13952    out.push_str("\n=== Biometric service ===\n");
13953    let ps_bio_svc = r#"
13954$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
13955if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
13956else { "WbioSrvc not found" }
13957"#;
13958    match run_powershell(ps_bio_svc) {
13959        Ok(o) => {
13960            let _ = writeln!(out, "- {}", o.trim());
13961        }
13962        Err(_) => out.push_str("- Could not query biometric service\n"),
13963    }
13964
13965    // Recent logon failure events
13966    out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
13967    let ps_events = r#"
13968$cutoff = (Get-Date).AddHours(-24)
13969Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
13970ForEach-Object {
13971    $xml = [xml]$_.ToXml()
13972    $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
13973    $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
13974    "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
13975} | Select-Object -First 10
13976"#;
13977    match run_powershell(ps_events) {
13978        Ok(o) if !o.trim().is_empty() => {
13979            let count = o.lines().filter(|l| !l.trim().is_empty()).count();
13980            let _ = writeln!(out, "- {count} recent logon failure(s) detected:");
13981            for line in o.lines().take(max_entries) {
13982                let l = line.trim();
13983                if !l.is_empty() {
13984                    let _ = writeln!(out, "  {l}");
13985                }
13986            }
13987        }
13988        _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
13989    }
13990
13991    // Credential providers
13992    out.push_str("\n=== Active credential providers ===\n");
13993    let ps_cp = r#"
13994Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
13995ForEach-Object {
13996    $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13997    if ($name) { $name }
13998} | Select-Object -First 15
13999"#;
14000    match run_powershell(ps_cp) {
14001        Ok(o) if !o.trim().is_empty() => {
14002            for line in o.lines().take(max_entries) {
14003                let l = line.trim();
14004                if !l.is_empty() {
14005                    let _ = writeln!(out, "- {l}");
14006                }
14007            }
14008        }
14009        _ => out.push_str("- Could not enumerate credential providers\n"),
14010    }
14011
14012    let mut findings: Vec<String> = Vec::with_capacity(4);
14013    if out.contains("WbioSrvc | Status: Stopped") {
14014        findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
14015    }
14016    if out.contains("recent logon failure") && !out.contains("0 recent") {
14017        findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
14018    }
14019
14020    let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
14021    if findings.is_empty() {
14022        result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
14023        result.push_str("  If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
14024    } else {
14025        for f in &findings {
14026            let _ = writeln!(result, "- Finding: {f}");
14027        }
14028    }
14029    result.push('\n');
14030    result.push_str(&out);
14031    Ok(result)
14032}
14033
14034#[cfg(not(windows))]
14035fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
14036    Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
14037}
14038
14039// ── inspect_installer_health ──────────────────────────────────────────────────
14040
14041#[cfg(windows)]
14042fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
14043    let mut out = String::from("=== Installer engines ===\n");
14044
14045    let ps_engines = r#"
14046$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
14047foreach ($name in $services) {
14048    $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
14049    if ($svc) {
14050        $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14051        $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
14052        "$name | Status: $($svc.Status) | StartType: $startType"
14053    } else {
14054        "$name | Not present"
14055    }
14056}
14057if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
14058    "msiexec.exe | Present: Yes"
14059} else {
14060    "msiexec.exe | Present: No"
14061}
14062"#;
14063    match run_powershell(ps_engines) {
14064        Ok(o) if !o.trim().is_empty() => {
14065            for line in o.lines().take(max_entries + 6) {
14066                let l = line.trim();
14067                if !l.is_empty() {
14068                    let _ = writeln!(out, "- {l}");
14069                }
14070            }
14071        }
14072        _ => out.push_str("- Could not inspect installer engine services\n"),
14073    }
14074
14075    out.push_str("\n=== winget and App Installer ===\n");
14076    let ps_winget = r#"
14077$cmd = Get-Command winget -ErrorAction SilentlyContinue
14078if ($cmd) {
14079    try {
14080        $v = & winget --version 2>$null
14081        if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
14082    } catch { "winget | Present but invocation failed" }
14083} else {
14084    "winget | Missing"
14085}
14086$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
14087if ($appInstaller) {
14088    "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
14089} else {
14090    "DesktopAppInstaller | Status: Missing"
14091}
14092"#;
14093    match run_powershell(ps_winget) {
14094        Ok(o) if !o.trim().is_empty() => {
14095            for line in o.lines().take(max_entries) {
14096                let l = line.trim();
14097                if !l.is_empty() {
14098                    let _ = writeln!(out, "- {l}");
14099                }
14100            }
14101        }
14102        _ => out.push_str("- Could not inspect winget/App Installer state\n"),
14103    }
14104
14105    out.push_str("\n=== Microsoft Store packages ===\n");
14106    let ps_store = r#"
14107$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
14108if ($store) {
14109    "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
14110} else {
14111    "Microsoft.WindowsStore | Status: Missing"
14112}
14113"#;
14114    match run_powershell(ps_store) {
14115        Ok(o) if !o.trim().is_empty() => {
14116            for line in o.lines().take(max_entries) {
14117                let l = line.trim();
14118                if !l.is_empty() {
14119                    let _ = writeln!(out, "- {l}");
14120                }
14121            }
14122        }
14123        _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
14124    }
14125
14126    out.push_str("\n=== Reboot and transaction blockers ===\n");
14127    let ps_blockers = r#"
14128$pending = $false
14129if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
14130    "RebootPending: CBS"
14131    $pending = $true
14132}
14133if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
14134    "RebootPending: WindowsUpdate"
14135    $pending = $true
14136}
14137$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
14138if ($rename) {
14139    "PendingFileRenameOperations: Yes"
14140    $pending = $true
14141}
14142if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
14143    "InstallerInProgress: Yes"
14144    $pending = $true
14145}
14146if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
14147"#;
14148    match run_powershell(ps_blockers) {
14149        Ok(o) if !o.trim().is_empty() => {
14150            for line in o.lines().take(max_entries) {
14151                let l = line.trim();
14152                if !l.is_empty() {
14153                    let _ = writeln!(out, "- {l}");
14154                }
14155            }
14156        }
14157        _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
14158    }
14159
14160    out.push_str("\n=== Recent installer failures (7d) ===\n");
14161    let ps_failures = r#"
14162$cutoff = (Get-Date).AddDays(-7)
14163$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
14164    ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
14165$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
14166    Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
14167    Select-Object -First 6 |
14168    ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
14169$all = @($msi) + @($appx)
14170if ($all.Count -eq 0) {
14171    "No recent MSI/AppX installer errors detected"
14172} else {
14173    $all | Select-Object -First 8
14174}
14175"#;
14176    match run_powershell(ps_failures) {
14177        Ok(o) if !o.trim().is_empty() => {
14178            for line in o.lines().take(max_entries + 2) {
14179                let l = line.trim();
14180                if !l.is_empty() {
14181                    let _ = writeln!(out, "- {l}");
14182                }
14183            }
14184        }
14185        _ => out.push_str("- Could not inspect recent installer failure events\n"),
14186    }
14187
14188    let mut findings: Vec<String> = Vec::with_capacity(4);
14189    if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
14190        findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
14191    }
14192    if out.contains("msiexec.exe | Present: No") {
14193        findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
14194    }
14195    if out.contains("winget | Missing") {
14196        findings.push(
14197            "winget is missing - App Installer may not be installed or registered for this user."
14198                .into(),
14199        );
14200    }
14201    if out.contains("DesktopAppInstaller | Status: Missing") {
14202        findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
14203    }
14204    if out.contains("Microsoft.WindowsStore | Status: Missing") {
14205        findings.push(
14206            "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
14207                .into(),
14208        );
14209    }
14210    if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
14211        findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
14212    }
14213    if out.contains("InstallerInProgress: Yes") {
14214        findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
14215    }
14216    if out.contains("MSI | ") || out.contains("AppX | ") {
14217        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());
14218    }
14219
14220    let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
14221    if findings.is_empty() {
14222        result.push_str("- No obvious installer-platform blocker detected.\n");
14223    } else {
14224        for finding in &findings {
14225            let _ = writeln!(result, "- Finding: {finding}");
14226        }
14227    }
14228    result.push('\n');
14229    result.push_str(&out);
14230    Ok(result)
14231}
14232
14233#[cfg(not(windows))]
14234fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
14235    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())
14236}
14237
14238// ── inspect_search_index ──────────────────────────────────────────────────────
14239
14240#[cfg(windows)]
14241fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
14242    let mut out = String::from("=== OneDrive client ===\n");
14243
14244    let ps_client = r#"
14245$candidatePaths = @(
14246    (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
14247    (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
14248    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
14249) | Where-Object { $_ -and (Test-Path $_) }
14250$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
14251$exe = $candidatePaths | Select-Object -First 1
14252if (-not $exe -and $proc) {
14253    try { $exe = $proc.Path } catch {}
14254}
14255if ($exe) {
14256    "Installed: Yes"
14257    "Executable: $exe"
14258    try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
14259} else {
14260    "Installed: Unknown"
14261}
14262if ($proc) {
14263    "Process: Running | PID: $($proc.Id)"
14264} else {
14265    "Process: Not running"
14266}
14267"#;
14268    match run_powershell(ps_client) {
14269        Ok(o) if !o.trim().is_empty() => {
14270            for line in o.lines().take(max_entries) {
14271                let l = line.trim();
14272                if !l.is_empty() {
14273                    let _ = writeln!(out, "- {l}");
14274                }
14275            }
14276        }
14277        _ => out.push_str("- Could not inspect OneDrive client state\n"),
14278    }
14279
14280    out.push_str("\n=== OneDrive accounts ===\n");
14281    let ps_accounts = r#"
14282function MaskEmail([string]$Email) {
14283    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
14284    $parts = $Email.Split('@', 2)
14285    $local = $parts[0]
14286    $domain = $parts[1]
14287    if ($local.Length -le 1) { return "*@$domain" }
14288    return ($local.Substring(0,1) + "***@" + $domain)
14289}
14290$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
14291if (Test-Path $base) {
14292    Get-ChildItem $base -ErrorAction SilentlyContinue |
14293        Sort-Object PSChildName |
14294        Select-Object -First 12 |
14295        ForEach-Object {
14296            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14297            $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
14298            $mail = MaskEmail ([string]$p.UserEmail)
14299            $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
14300            $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
14301            "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
14302        }
14303} else {
14304    "No OneDrive accounts configured"
14305}
14306"#;
14307    match run_powershell(ps_accounts) {
14308        Ok(o) if !o.trim().is_empty() => {
14309            for line in o.lines().take(max_entries) {
14310                let l = line.trim();
14311                if !l.is_empty() {
14312                    let _ = writeln!(out, "- {l}");
14313                }
14314            }
14315        }
14316        _ => out.push_str("- Could not read OneDrive account registry state\n"),
14317    }
14318
14319    out.push_str("\n=== OneDrive policy overrides ===\n");
14320    let ps_policy = r#"
14321$paths = @(
14322    'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
14323    'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
14324)
14325$names = @(
14326    'DisableFileSyncNGSC',
14327    'DisableLibrariesDefaultSaveToOneDrive',
14328    'KFMSilentOptIn',
14329    'KFMBlockOptIn',
14330    'SilentAccountConfig'
14331)
14332$found = $false
14333foreach ($path in $paths) {
14334    if (Test-Path $path) {
14335        $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
14336        foreach ($name in $names) {
14337            $value = $p.$name
14338            if ($null -ne $value -and [string]$value -ne '') {
14339                "$path | $name=$value"
14340                $found = $true
14341            }
14342        }
14343    }
14344}
14345if (-not $found) { "No OneDrive policy overrides detected" }
14346"#;
14347    match run_powershell(ps_policy) {
14348        Ok(o) if !o.trim().is_empty() => {
14349            for line in o.lines().take(max_entries) {
14350                let l = line.trim();
14351                if !l.is_empty() {
14352                    let _ = writeln!(out, "- {l}");
14353                }
14354            }
14355        }
14356        _ => out.push_str("- Could not read OneDrive policy state\n"),
14357    }
14358
14359    out.push_str("\n=== Known Folder Backup ===\n");
14360    let ps_kfm = r#"
14361$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
14362$roots = @()
14363if (Test-Path $base) {
14364    Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
14365        $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14366        if ($p.UserFolder) {
14367            $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
14368        }
14369    }
14370}
14371$roots = $roots | Select-Object -Unique
14372$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
14373if (Test-Path $shell) {
14374    $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
14375    $folders = @(
14376        @{ Name='Desktop'; Value=$props.Desktop },
14377        @{ Name='Documents'; Value=$props.Personal },
14378        @{ Name='Pictures'; Value=$props.'My Pictures' }
14379    )
14380    foreach ($folder in $folders) {
14381        $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
14382        if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
14383        $protected = $false
14384        foreach ($root in $roots) {
14385            if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
14386                $protected = $true
14387                break
14388            }
14389        }
14390        "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
14391    }
14392} else {
14393    "Explorer shell folders unavailable"
14394}
14395"#;
14396    match run_powershell(ps_kfm) {
14397        Ok(o) if !o.trim().is_empty() => {
14398            for line in o.lines().take(max_entries) {
14399                let l = line.trim();
14400                if !l.is_empty() {
14401                    let _ = writeln!(out, "- {l}");
14402                }
14403            }
14404        }
14405        _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
14406    }
14407
14408    let mut findings: Vec<String> = Vec::with_capacity(4);
14409    if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
14410        findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
14411    }
14412    if out.contains("No OneDrive accounts configured") {
14413        findings.push(
14414            "No OneDrive accounts are configured - sync cannot start until the user signs in."
14415                .into(),
14416        );
14417    }
14418    if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
14419        findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
14420    }
14421    if out.contains("Exists: No") {
14422        findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
14423    }
14424    if out.contains("DisableFileSyncNGSC=1") {
14425        findings
14426            .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
14427    }
14428    if out.contains("KFMBlockOptIn=1") {
14429        findings
14430            .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
14431    }
14432    if out.contains("SyncRoot: C:\\") {
14433        let mut missing_kfm: Vec<&str> = Vec::new();
14434        for folder in ["Desktop", "Documents", "Pictures"] {
14435            if out.lines().any(|line| {
14436                line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
14437            }) {
14438                missing_kfm.push(folder);
14439            }
14440        }
14441        if !missing_kfm.is_empty() {
14442            findings.push(format!(
14443                "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
14444                missing_kfm.join(", ")
14445            ));
14446        }
14447    }
14448
14449    let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
14450    if findings.is_empty() {
14451        result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
14452    } else {
14453        for finding in &findings {
14454            let _ = writeln!(result, "- Finding: {finding}");
14455        }
14456    }
14457    result.push('\n');
14458    result.push_str(&out);
14459    Ok(result)
14460}
14461
14462#[cfg(not(windows))]
14463fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
14464    Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
14465}
14466
14467#[cfg(windows)]
14468fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
14469    let mut out = String::from("=== Browser inventory ===\n");
14470
14471    let ps_inventory = r#"
14472$browsers = @(
14473    @{ Name='Edge'; Paths=@(
14474        (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
14475        (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
14476    ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
14477    @{ Name='Chrome'; Paths=@(
14478        (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
14479        (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
14480        (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
14481    ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
14482    @{ Name='Firefox'; Paths=@(
14483        (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
14484        (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
14485    ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
14486)
14487foreach ($browser in $browsers) {
14488    $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14489    if ($exe) {
14490        $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14491        $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
14492        "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
14493    } else {
14494        "$($browser.Name) | Installed: No"
14495    }
14496}
14497$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
14498$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
14499$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
14500"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
14501"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
14502"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
14503"#;
14504    match run_powershell(ps_inventory) {
14505        Ok(o) if !o.trim().is_empty() => {
14506            for line in o.lines().take(max_entries + 6) {
14507                let l = line.trim();
14508                if !l.is_empty() {
14509                    let _ = writeln!(out, "- {l}");
14510                }
14511            }
14512        }
14513        _ => out.push_str("- Could not inspect installed browser inventory\n"),
14514    }
14515
14516    out.push_str("\n=== Runtime state ===\n");
14517    let ps_runtime = r#"
14518$targets = 'msedge','chrome','firefox','msedgewebview2'
14519foreach ($name in $targets) {
14520    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14521    if ($procs) {
14522        $count = @($procs).Count
14523        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14524        "$name | Processes: $count | WorkingSetMB: $wsMb"
14525    } else {
14526        "$name | Processes: 0 | WorkingSetMB: 0"
14527    }
14528}
14529"#;
14530    match run_powershell(ps_runtime) {
14531        Ok(o) if !o.trim().is_empty() => {
14532            for line in o.lines().take(max_entries + 4) {
14533                let l = line.trim();
14534                if !l.is_empty() {
14535                    let _ = writeln!(out, "- {l}");
14536                }
14537            }
14538        }
14539        _ => out.push_str("- Could not inspect browser runtime state\n"),
14540    }
14541
14542    out.push_str("\n=== WebView2 runtime ===\n");
14543    let ps_webview = r#"
14544$paths = @(
14545    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14546    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14547) | Where-Object { $_ -and (Test-Path $_) }
14548$runtimeDir = $paths | ForEach-Object {
14549    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14550        Where-Object { $_.Name -match '^\d+\.' } |
14551        Sort-Object Name -Descending |
14552        Select-Object -First 1
14553} | Select-Object -First 1
14554if ($runtimeDir) {
14555    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14556    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14557    "Installed: Yes"
14558    "Version: $version"
14559    "Executable: $exe"
14560} else {
14561    "Installed: No"
14562}
14563$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
14564"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
14565"#;
14566    match run_powershell(ps_webview) {
14567        Ok(o) if !o.trim().is_empty() => {
14568            for line in o.lines().take(max_entries) {
14569                let l = line.trim();
14570                if !l.is_empty() {
14571                    let _ = writeln!(out, "- {l}");
14572                }
14573            }
14574        }
14575        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14576    }
14577
14578    out.push_str("\n=== Policy and proxy surface ===\n");
14579    let ps_policy = r#"
14580$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
14581$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
14582$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
14583$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
14584$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
14585"UserProxyEnabled: $proxyEnabled"
14586"UserProxyServer: $proxyServer"
14587"UserAutoConfigURL: $autoConfig"
14588"UserAutoDetect: $autoDetect"
14589$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
14590if ($winhttp) {
14591    $normalized = ($winhttp -replace '\s+', ' ').Trim()
14592    $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
14593    "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
14594    "WinHTTP: $normalized"
14595}
14596$policyTargets = @(
14597    @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
14598    @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
14599)
14600foreach ($policy in $policyTargets) {
14601    if (Test-Path $policy.Path) {
14602        $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
14603        foreach ($key in $policy.Keys) {
14604            $value = $item.$key
14605            if ($null -ne $value -and [string]$value -ne '') {
14606                if ($value -is [array]) {
14607                    "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
14608                } else {
14609                    "$($policy.Name)Policy | $key=$value"
14610                }
14611            }
14612        }
14613    }
14614}
14615"#;
14616    match run_powershell(ps_policy) {
14617        Ok(o) if !o.trim().is_empty() => {
14618            for line in o.lines().take(max_entries + 8) {
14619                let l = line.trim();
14620                if !l.is_empty() {
14621                    let _ = writeln!(out, "- {l}");
14622                }
14623            }
14624        }
14625        _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
14626    }
14627
14628    out.push_str("\n=== Profile and cache pressure ===\n");
14629    let ps_profiles = r#"
14630$profiles = @(
14631    @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
14632    @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
14633    @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
14634)
14635foreach ($profile in $profiles) {
14636    if (Test-Path $profile.Root) {
14637        if ($profile.Name -eq 'Firefox') {
14638            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
14639        } else {
14640            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
14641                Where-Object {
14642                    $_.Name -eq 'Default' -or
14643                    $_.Name -eq 'Guest Profile' -or
14644                    $_.Name -eq 'System Profile' -or
14645                    $_.Name -like 'Profile *'
14646                }
14647        }
14648        $profileCount = @($dirs).Count
14649        $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
14650        if (-not $sizeBytes) { $sizeBytes = 0 }
14651        $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
14652        $extCount = 'Unknown'
14653        if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
14654            $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
14655        }
14656        "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
14657    } else {
14658        "$($profile.Name) | ProfileRoot: Missing"
14659    }
14660}
14661"#;
14662    match run_powershell(ps_profiles) {
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                    let _ = writeln!(out, "- {l}");
14668                }
14669            }
14670        }
14671        _ => out.push_str("- Could not inspect browser profile pressure\n"),
14672    }
14673
14674    out.push_str("\n=== Recent browser failures (7d) ===\n");
14675    let ps_failures = r#"
14676$cutoff = (Get-Date).AddDays(-7)
14677$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
14678$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
14679    Where-Object {
14680        $msg = [string]$_.Message
14681        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14682        ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
14683    } |
14684    Select-Object -First 6
14685if ($events) {
14686    foreach ($event in $events) {
14687        $msg = ($event.Message -replace '\s+', ' ')
14688        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14689        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14690    }
14691} else {
14692    "No recent browser crash or WER events detected"
14693}
14694"#;
14695    match run_powershell(ps_failures) {
14696        Ok(o) if !o.trim().is_empty() => {
14697            for line in o.lines().take(max_entries + 2) {
14698                let l = line.trim();
14699                if !l.is_empty() {
14700                    let _ = writeln!(out, "- {l}");
14701                }
14702            }
14703        }
14704        _ => out.push_str("- Could not inspect recent browser failure events\n"),
14705    }
14706
14707    let mut findings: Vec<String> = Vec::with_capacity(4);
14708    if out.contains("Edge | Installed: No")
14709        && out.contains("Chrome | Installed: No")
14710        && out.contains("Firefox | Installed: No")
14711    {
14712        findings.push(
14713            "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
14714                .into(),
14715        );
14716    }
14717    if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
14718        findings.push(
14719            "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
14720                .into(),
14721        );
14722    }
14723    if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
14724        findings.push(
14725            "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
14726                .into(),
14727        );
14728    }
14729    if out.contains("EdgePolicy | Proxy")
14730        || out.contains("ChromePolicy | Proxy")
14731        || out.contains("ExtensionInstallForcelist=")
14732    {
14733        findings.push(
14734            "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
14735                .into(),
14736        );
14737    }
14738    for browser in ["msedge", "chrome", "firefox"] {
14739        let process_marker = format!("{browser} | Processes: ");
14740        if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
14741            let count = line
14742                .split("| Processes: ")
14743                .nth(1)
14744                .and_then(|rest| rest.split(" |").next())
14745                .and_then(|value| value.trim().parse::<usize>().ok())
14746                .unwrap_or(0);
14747            let ws_mb = line
14748                .split("| WorkingSetMB: ")
14749                .nth(1)
14750                .and_then(|value| value.trim().parse::<f64>().ok())
14751                .unwrap_or(0.0);
14752            if count >= 25 {
14753                findings.push(format!(
14754                    "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
14755                ));
14756            } else if ws_mb >= 2500.0 {
14757                findings.push(format!(
14758                    "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
14759                ));
14760            }
14761        }
14762    }
14763    if out.contains("=== WebView2 runtime ===\n- Installed: No")
14764        || (out.contains("=== WebView2 runtime ===")
14765            && out.contains("- Installed: No")
14766            && out.contains("- ProcessCount: 0"))
14767    {
14768        findings.push(
14769            "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
14770                .into(),
14771        );
14772    }
14773    for browser in ["Edge", "Chrome", "Firefox"] {
14774        let prefix = format!("{browser} | ProfileRoot:");
14775        if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
14776            let size_gb = line
14777                .split("| SizeGB: ")
14778                .nth(1)
14779                .and_then(|rest| rest.split(" |").next())
14780                .and_then(|value| value.trim().parse::<f64>().ok())
14781                .unwrap_or(0.0);
14782            let ext_count = line
14783                .split("| Extensions: ")
14784                .nth(1)
14785                .and_then(|value| value.trim().parse::<usize>().ok())
14786                .unwrap_or(0);
14787            if size_gb >= 2.5 {
14788                findings.push(format!(
14789                    "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
14790                ));
14791            }
14792            if ext_count >= 20 {
14793                findings.push(format!(
14794                    "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
14795                ));
14796            }
14797        }
14798    }
14799    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14800        findings.push(
14801            "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
14802                .into(),
14803        );
14804    }
14805
14806    let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
14807    if findings.is_empty() {
14808        result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
14809    } else {
14810        for finding in &findings {
14811            let _ = writeln!(result, "- Finding: {finding}");
14812        }
14813    }
14814    result.push('\n');
14815    result.push_str(&out);
14816    Ok(result)
14817}
14818
14819#[cfg(not(windows))]
14820fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
14821    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())
14822}
14823
14824#[cfg(windows)]
14825fn inspect_outlook(max_entries: usize) -> Result<String, String> {
14826    let mut out = String::from("=== Outlook install inventory ===\n");
14827
14828    let ps_install = r#"
14829$installPaths = @(
14830    (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14831    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
14832    (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
14833    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
14834    (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
14835    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
14836)
14837$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14838if ($exe) {
14839    $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
14840    $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
14841    "Installed: Yes"
14842    "Executable: $exe"
14843    "Version: $version"
14844    "Product: $productName"
14845} else {
14846    "Installed: No"
14847}
14848$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
14849if ($newOutlook) {
14850    "NewOutlook: Installed | Version: $($newOutlook.Version)"
14851} else {
14852    "NewOutlook: Not installed"
14853}
14854"#;
14855    match run_powershell(ps_install) {
14856        Ok(o) if !o.trim().is_empty() => {
14857            for line in o.lines().take(max_entries + 4) {
14858                let l = line.trim();
14859                if !l.is_empty() {
14860                    let _ = writeln!(out, "- {l}");
14861                }
14862            }
14863        }
14864        _ => out.push_str("- Could not inspect Outlook install paths\n"),
14865    }
14866
14867    out.push_str("\n=== Runtime state ===\n");
14868    let ps_runtime = r#"
14869$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
14870if ($proc) {
14871    $count = @($proc).Count
14872    $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14873    $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
14874    "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
14875} else {
14876    "Running: No"
14877}
14878"#;
14879    match run_powershell(ps_runtime) {
14880        Ok(o) if !o.trim().is_empty() => {
14881            for line in o.lines().take(4) {
14882                let l = line.trim();
14883                if !l.is_empty() {
14884                    let _ = writeln!(out, "- {l}");
14885                }
14886            }
14887        }
14888        _ => out.push_str("- Could not inspect Outlook runtime state\n"),
14889    }
14890
14891    out.push_str("\n=== Mail profiles ===\n");
14892    let ps_profiles = r#"
14893$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
14894if (-not (Test-Path $profileKey)) {
14895    $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
14896}
14897if (Test-Path $profileKey) {
14898    $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
14899    $count = @($profiles).Count
14900    "ProfileCount: $count"
14901    foreach ($p in $profiles | Select-Object -First 10) {
14902        "Profile: $($p.PSChildName)"
14903    }
14904} else {
14905    "ProfileCount: 0"
14906    "No Outlook profiles found in registry"
14907}
14908"#;
14909    match run_powershell(ps_profiles) {
14910        Ok(o) if !o.trim().is_empty() => {
14911            for line in o.lines().take(max_entries + 2) {
14912                let l = line.trim();
14913                if !l.is_empty() {
14914                    let _ = writeln!(out, "- {l}");
14915                }
14916            }
14917        }
14918        _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
14919    }
14920
14921    out.push_str("\n=== OST and PST data files ===\n");
14922    let ps_datafiles = r#"
14923$searchRoots = @(
14924    (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
14925    (Join-Path $env:USERPROFILE 'Documents'),
14926    (Join-Path $env:USERPROFILE 'OneDrive\Documents')
14927) | Where-Object { $_ -and (Test-Path $_) }
14928$files = foreach ($root in $searchRoots) {
14929    Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
14930        Select-Object FullName,
14931            @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
14932            @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
14933            LastWriteTime
14934}
14935if ($files) {
14936    foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
14937        "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
14938    }
14939} else {
14940    "No OST or PST files found in standard locations"
14941}
14942"#;
14943    match run_powershell(ps_datafiles) {
14944        Ok(o) if !o.trim().is_empty() => {
14945            for line in o.lines().take(max_entries + 4) {
14946                let l = line.trim();
14947                if !l.is_empty() {
14948                    let _ = writeln!(out, "- {l}");
14949                }
14950            }
14951        }
14952        _ => out.push_str("- Could not inspect OST/PST data files\n"),
14953    }
14954
14955    out.push_str("\n=== Add-in pressure ===\n");
14956    let ps_addins = r#"
14957$addinPaths = @(
14958    'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14959    'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
14960    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
14961)
14962$addins = foreach ($path in $addinPaths) {
14963    if (Test-Path $path) {
14964        Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
14965            $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14966            $loadBehavior = $item.LoadBehavior
14967            $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
14968            [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
14969        }
14970    }
14971}
14972$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
14973$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
14974"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
14975foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
14976    $state = switch ($a.LoadBehavior) {
14977        0 { 'Disabled' }
14978        2 { 'LoadOnStart(inactive)' }
14979        3 { 'ActiveOnStart' }
14980        8 { 'DemandLoad' }
14981        9 { 'ActiveDemand' }
14982        16 { 'ConnectedFirst' }
14983        default { "LoadBehavior=$($a.LoadBehavior)" }
14984    }
14985    "$($a.Name) | $state"
14986}
14987$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
14988$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
14989if (Test-Path $disabledByResiliency) {
14990    $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
14991    $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
14992    if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
14993}
14994"#;
14995    match run_powershell(ps_addins) {
14996        Ok(o) if !o.trim().is_empty() => {
14997            for line in o.lines().take(max_entries + 8) {
14998                let l = line.trim();
14999                if !l.is_empty() {
15000                    let _ = writeln!(out, "- {l}");
15001                }
15002            }
15003        }
15004        _ => out.push_str("- Could not inspect Outlook add-ins\n"),
15005    }
15006
15007    out.push_str("\n=== Authentication and cache friction ===\n");
15008    let ps_auth = r#"
15009$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15010$tokenCount = if (Test-Path $tokenCache) {
15011    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
15012} else { 0 }
15013"TokenBrokerCacheFiles: $tokenCount"
15014$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
15015$credsCount = @($credentialManager).Count
15016"OfficeCredentialsInVault: $credsCount"
15017$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15018if (Test-Path $samlKey) {
15019    $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
15020    $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
15021    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
15022    "WAMOverride: $connected"
15023    "SignedInUserId: $signedIn"
15024}
15025$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
15026if (Test-Path $outlookReg) {
15027    $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
15028    if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
15029}
15030"#;
15031    match run_powershell(ps_auth) {
15032        Ok(o) if !o.trim().is_empty() => {
15033            for line in o.lines().take(max_entries + 4) {
15034                let l = line.trim();
15035                if !l.is_empty() {
15036                    let _ = writeln!(out, "- {l}");
15037                }
15038            }
15039        }
15040        _ => out.push_str("- Could not inspect Outlook auth state\n"),
15041    }
15042
15043    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
15044    let ps_events = r#"
15045$cutoff = (Get-Date).AddDays(-7)
15046$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15047    Where-Object {
15048        $msg = [string]$_.Message
15049        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
15050        ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
15051    } |
15052    Select-Object -First 8
15053if ($events) {
15054    foreach ($event in $events) {
15055        $msg = ($event.Message -replace '\s+', ' ')
15056        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15057        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15058    }
15059} else {
15060    "No recent Outlook crash or error events detected in Application log"
15061}
15062"#;
15063    match run_powershell(ps_events) {
15064        Ok(o) if !o.trim().is_empty() => {
15065            for line in o.lines().take(max_entries + 4) {
15066                let l = line.trim();
15067                if !l.is_empty() {
15068                    let _ = writeln!(out, "- {l}");
15069                }
15070            }
15071        }
15072        _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
15073    }
15074
15075    let mut findings: Vec<String> = Vec::with_capacity(4);
15076
15077    if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
15078        findings.push(
15079            "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
15080                .into(),
15081        );
15082    }
15083
15084    if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
15085        let ws_mb = line
15086            .split("WorkingSetMB: ")
15087            .nth(1)
15088            .and_then(|r| r.split(" |").next())
15089            .and_then(|v| v.trim().parse::<f64>().ok())
15090            .unwrap_or(0.0);
15091        if ws_mb >= 1500.0 {
15092            findings.push(format!(
15093                "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
15094            ));
15095        }
15096    }
15097
15098    let large_ost: Vec<String> = out
15099        .lines()
15100        .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
15101        .filter_map(|l| {
15102            let mb = l
15103                .split("SizeMB: ")
15104                .nth(1)
15105                .and_then(|r| r.split(" |").next())
15106                .and_then(|v| v.trim().parse::<f64>().ok())
15107                .unwrap_or(0.0);
15108            if mb >= 10_000.0 {
15109                Some(format!("{mb:.0} MB OST file detected"))
15110            } else {
15111                None
15112            }
15113        })
15114        .collect();
15115    for msg in large_ost {
15116        findings.push(format!(
15117            "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
15118        ));
15119    }
15120
15121    if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
15122        let active_count = line
15123            .split("Active: ")
15124            .nth(1)
15125            .and_then(|r| r.split(" |").next())
15126            .and_then(|v| v.trim().parse::<usize>().ok())
15127            .unwrap_or(0);
15128        if active_count >= 8 {
15129            findings.push(format!(
15130                "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
15131            ));
15132        }
15133    }
15134
15135    if out.contains("ResiliencyDisabledItems:") {
15136        findings.push(
15137            "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
15138                .into(),
15139        );
15140    }
15141
15142    if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
15143        findings.push(
15144            "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
15145                .into(),
15146        );
15147    }
15148
15149    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
15150        findings.push(
15151            "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)."
15152                .into(),
15153        );
15154    }
15155
15156    let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
15157    if findings.is_empty() {
15158        result.push_str("- No obvious Outlook health blocker detected.\n");
15159    } else {
15160        for finding in &findings {
15161            let _ = writeln!(result, "- Finding: {finding}");
15162        }
15163    }
15164    result.push('\n');
15165    result.push_str(&out);
15166    Ok(result)
15167}
15168
15169#[cfg(not(windows))]
15170fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
15171    Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
15172}
15173
15174#[cfg(windows)]
15175fn inspect_teams(max_entries: usize) -> Result<String, String> {
15176    let mut out = String::from("=== Teams install inventory ===\n");
15177
15178    let ps_install = r#"
15179# Classic Teams (Teams 1.0)
15180$classicExe = @(
15181    (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
15182    (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
15183) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
15184
15185if ($classicExe) {
15186    $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
15187    "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
15188} else {
15189    "ClassicTeams: Not installed"
15190}
15191
15192# New Teams (Teams 2.0 / ms-teams.exe)
15193$newTeamsExe = @(
15194    (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
15195    (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
15196) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
15197
15198$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
15199if ($newTeamsPkg) {
15200    "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
15201} elseif ($newTeamsExe) {
15202    $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
15203    "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
15204} else {
15205    "NewTeams: Not installed"
15206}
15207
15208# Teams Machine-Wide Installer (MSI/per-machine)
15209$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
15210    Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
15211    Select-Object -First 1
15212if ($mwi) {
15213    "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
15214} else {
15215    "MachineWideInstaller: Not found"
15216}
15217"#;
15218    match run_powershell(ps_install) {
15219        Ok(o) if !o.trim().is_empty() => {
15220            for line in o.lines().take(max_entries + 4) {
15221                let l = line.trim();
15222                if !l.is_empty() {
15223                    let _ = writeln!(out, "- {l}");
15224                }
15225            }
15226        }
15227        _ => out.push_str("- Could not inspect Teams install paths\n"),
15228    }
15229
15230    out.push_str("\n=== Runtime state ===\n");
15231    let ps_runtime = r#"
15232$targets = @('Teams','ms-teams')
15233foreach ($name in $targets) {
15234    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
15235    if ($procs) {
15236        $count = @($procs).Count
15237        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
15238        "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
15239    } else {
15240        "$name | Running: No"
15241    }
15242}
15243"#;
15244    match run_powershell(ps_runtime) {
15245        Ok(o) if !o.trim().is_empty() => {
15246            for line in o.lines().take(6) {
15247                let l = line.trim();
15248                if !l.is_empty() {
15249                    let _ = writeln!(out, "- {l}");
15250                }
15251            }
15252        }
15253        _ => out.push_str("- Could not inspect Teams runtime state\n"),
15254    }
15255
15256    out.push_str("\n=== Cache directory sizing ===\n");
15257    let ps_cache = r#"
15258$cachePaths = @(
15259    @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
15260    @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
15261    @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
15262    @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
15263)
15264foreach ($entry in $cachePaths) {
15265    if (Test-Path $entry.Path) {
15266        $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
15267        if (-not $sizeBytes) { $sizeBytes = 0 }
15268        $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
15269        "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
15270    } else {
15271        "$($entry.Name) | Path: $($entry.Path) | Not found"
15272    }
15273}
15274"#;
15275    match run_powershell(ps_cache) {
15276        Ok(o) if !o.trim().is_empty() => {
15277            for line in o.lines().take(max_entries + 4) {
15278                let l = line.trim();
15279                if !l.is_empty() {
15280                    let _ = writeln!(out, "- {l}");
15281                }
15282            }
15283        }
15284        _ => out.push_str("- Could not inspect Teams cache directories\n"),
15285    }
15286
15287    out.push_str("\n=== WebView2 runtime ===\n");
15288    let ps_webview = r#"
15289$paths = @(
15290    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
15291    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
15292) | Where-Object { $_ -and (Test-Path $_) }
15293$runtimeDir = $paths | ForEach-Object {
15294    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
15295        Where-Object { $_.Name -match '^\d+\.' } |
15296        Sort-Object Name -Descending |
15297        Select-Object -First 1
15298} | Select-Object -First 1
15299if ($runtimeDir) {
15300    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
15301    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
15302    "Installed: Yes | Version: $version"
15303} else {
15304    "Installed: No -- New Teams and some Office features require WebView2"
15305}
15306"#;
15307    match run_powershell(ps_webview) {
15308        Ok(o) if !o.trim().is_empty() => {
15309            for line in o.lines().take(4) {
15310                let l = line.trim();
15311                if !l.is_empty() {
15312                    let _ = writeln!(out, "- {l}");
15313                }
15314            }
15315        }
15316        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
15317    }
15318
15319    out.push_str("\n=== Account and sign-in state ===\n");
15320    let ps_auth = r#"
15321# Classic Teams account registry
15322$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
15323if (Test-Path $classicAcct) {
15324    $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
15325    $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
15326    "ClassicTeamsAccount: $email"
15327} else {
15328    "ClassicTeamsAccount: Not configured"
15329}
15330# WAM / token broker state for Teams
15331$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15332$tokenCount = if (Test-Path $tokenCache) {
15333    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
15334} else { 0 }
15335"TokenBrokerCacheFiles: $tokenCount"
15336# Office identity
15337$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15338if (Test-Path $officeId) {
15339    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
15340    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
15341    "OfficeSignedInUserId: $signedIn"
15342}
15343# Check if Teams is in startup
15344$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
15345$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
15346"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
15347"#;
15348    match run_powershell(ps_auth) {
15349        Ok(o) if !o.trim().is_empty() => {
15350            for line in o.lines().take(max_entries + 4) {
15351                let l = line.trim();
15352                if !l.is_empty() {
15353                    let _ = writeln!(out, "- {l}");
15354                }
15355            }
15356        }
15357        _ => out.push_str("- Could not inspect Teams account state\n"),
15358    }
15359
15360    out.push_str("\n=== Audio and video device binding ===\n");
15361    let ps_devices = r#"
15362# Teams stores device prefs in the settings file
15363$settingsPaths = @(
15364    (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
15365    (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
15366)
15367$found = $false
15368foreach ($sp in $settingsPaths) {
15369    if (Test-Path $sp) {
15370        $found = $true
15371        $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
15372        if ($raw) {
15373            $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
15374            if ($json) {
15375                $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
15376                $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
15377                $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
15378                "ConfigFile: $sp"
15379                "Microphone: $mic"
15380                "Speaker: $spk"
15381                "Camera: $cam"
15382            } else {
15383                "ConfigFile: $sp (not parseable as JSON)"
15384            }
15385        } else {
15386            "ConfigFile: $sp (empty)"
15387        }
15388        break
15389    }
15390}
15391if (-not $found) {
15392    "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
15393}
15394"#;
15395    match run_powershell(ps_devices) {
15396        Ok(o) if !o.trim().is_empty() => {
15397            for line in o.lines().take(max_entries + 4) {
15398                let l = line.trim();
15399                if !l.is_empty() {
15400                    let _ = writeln!(out, "- {l}");
15401                }
15402            }
15403        }
15404        _ => out.push_str("- Could not inspect Teams device binding\n"),
15405    }
15406
15407    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
15408    let ps_events = r#"
15409$cutoff = (Get-Date).AddDays(-7)
15410$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
15411    Where-Object {
15412        $msg = [string]$_.Message
15413        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
15414        ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
15415    } |
15416    Select-Object -First 8
15417if ($events) {
15418    foreach ($event in $events) {
15419        $msg = ($event.Message -replace '\s+', ' ')
15420        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
15421        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
15422    }
15423} else {
15424    "No recent Teams crash or error events detected in Application log"
15425}
15426"#;
15427    match run_powershell(ps_events) {
15428        Ok(o) if !o.trim().is_empty() => {
15429            for line in o.lines().take(max_entries + 4) {
15430                let l = line.trim();
15431                if !l.is_empty() {
15432                    let _ = writeln!(out, "- {l}");
15433                }
15434            }
15435        }
15436        _ => out.push_str("- Could not inspect Teams event log evidence\n"),
15437    }
15438
15439    let mut findings: Vec<String> = Vec::with_capacity(4);
15440
15441    let classic_installed = out.contains("- ClassicTeams: Installed");
15442    let new_installed = out.contains("- NewTeams: Installed");
15443    if !classic_installed && !new_installed {
15444        findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
15445    }
15446
15447    for name in ["Teams", "ms-teams"] {
15448        let marker = format!("{name} | Running: Yes | Processes:");
15449        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
15450            let ws_mb = line
15451                .split("WorkingSetMB: ")
15452                .nth(1)
15453                .and_then(|v| v.trim().parse::<f64>().ok())
15454                .unwrap_or(0.0);
15455            if ws_mb >= 1000.0 {
15456                findings.push(format!(
15457                    "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
15458                ));
15459            }
15460        }
15461    }
15462
15463    for (label, threshold_mb) in [
15464        ("ClassicTeamsCache", 500.0_f64),
15465        ("ClassicTeamsSquirrel", 2000.0),
15466        ("NewTeamsCache", 500.0),
15467        ("NewTeamsAppData", 3000.0),
15468    ] {
15469        let marker = format!("{label} |");
15470        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
15471            let mb = line
15472                .split("SizeMB: ")
15473                .nth(1)
15474                .and_then(|v| v.trim().parse::<f64>().ok())
15475                .unwrap_or(0.0);
15476            if mb >= threshold_mb {
15477                findings.push(format!(
15478                    "{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."
15479                ));
15480            }
15481        }
15482    }
15483
15484    if out.contains("- Installed: No -- New Teams") {
15485        findings.push(
15486            "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
15487                .into(),
15488        );
15489    }
15490
15491    if out.contains("- ClassicTeamsAccount: Not configured")
15492        && out.contains("- OfficeSignedInUserId: None")
15493    {
15494        findings.push(
15495            "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
15496                .into(),
15497        );
15498    }
15499
15500    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
15501        findings.push(
15502            "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
15503                .into(),
15504        );
15505    }
15506
15507    let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
15508    if findings.is_empty() {
15509        result.push_str("- No obvious Teams health blocker detected.\n");
15510    } else {
15511        for finding in &findings {
15512            let _ = writeln!(result, "- Finding: {finding}");
15513        }
15514    }
15515    result.push('\n');
15516    result.push_str(&out);
15517    Ok(result)
15518}
15519
15520#[cfg(not(windows))]
15521fn inspect_teams(_max_entries: usize) -> Result<String, String> {
15522    Ok(
15523        "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
15524            .into(),
15525    )
15526}
15527
15528#[cfg(windows)]
15529fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
15530    let mut out = String::from("=== Identity broker services ===\n");
15531
15532    let ps_services = r#"
15533$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
15534foreach ($name in $serviceNames) {
15535    $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
15536    if ($svc) {
15537        "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
15538    } else {
15539        "$name | Not found"
15540    }
15541}
15542"#;
15543    match run_powershell(ps_services) {
15544        Ok(o) if !o.trim().is_empty() => {
15545            for line in o.lines().take(max_entries) {
15546                let l = line.trim();
15547                if !l.is_empty() {
15548                    let _ = writeln!(out, "- {l}");
15549                }
15550            }
15551        }
15552        _ => out.push_str("- Could not inspect identity broker services\n"),
15553    }
15554
15555    out.push_str("\n=== Device registration ===\n");
15556    let ps_device = r#"
15557$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
15558if ($dsreg) {
15559    try {
15560        $raw = & $dsreg.Source /status 2>$null
15561        $text = ($raw -join "`n")
15562        $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
15563        $seen = $false
15564        foreach ($key in $keys) {
15565            $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
15566            if ($match.Success) {
15567                "${key}: $($match.Groups[1].Value.Trim())"
15568                $seen = $true
15569            }
15570        }
15571        if (-not $seen) {
15572            "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
15573        }
15574    } catch {
15575        "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
15576    }
15577} else {
15578    "DeviceRegistration: dsregcmd unavailable"
15579}
15580"#;
15581    match run_powershell(ps_device) {
15582        Ok(o) if !o.trim().is_empty() => {
15583            for line in o.lines().take(max_entries + 4) {
15584                let l = line.trim();
15585                if !l.is_empty() {
15586                    let _ = writeln!(out, "- {l}");
15587                }
15588            }
15589        }
15590        _ => out.push_str(
15591            "- DeviceRegistration: Could not inspect device registration state in this session\n",
15592        ),
15593    }
15594
15595    out.push_str("\n=== Broker packages and caches ===\n");
15596    let ps_broker = r#"
15597$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
15598if ($pkg) {
15599    "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
15600} else {
15601    "AADBrokerPlugin: Not installed"
15602}
15603$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
15604$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15605"TokenBrokerCacheFiles: $tokenCount"
15606$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
15607$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15608"IdentityCacheFiles: $identityCount"
15609$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
15610$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
15611"OneAuthFiles: $oneAuthCount"
15612"#;
15613    match run_powershell(ps_broker) {
15614        Ok(o) if !o.trim().is_empty() => {
15615            for line in o.lines().take(max_entries + 4) {
15616                let l = line.trim();
15617                if !l.is_empty() {
15618                    let _ = writeln!(out, "- {l}");
15619                }
15620            }
15621        }
15622        _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
15623    }
15624
15625    out.push_str("\n=== Microsoft app account signals ===\n");
15626    let ps_accounts = r#"
15627function MaskEmail([string]$Email) {
15628    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
15629    $parts = $Email.Split('@', 2)
15630    $local = $parts[0]
15631    $domain = $parts[1]
15632    if ($local.Length -le 1) { return "*@$domain" }
15633    return ($local.Substring(0,1) + "***@" + $domain)
15634}
15635$allAccounts = @()
15636$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
15637if (Test-Path $officeId) {
15638    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
15639    if ($id.SignedInUserId) {
15640        $allAccounts += [string]$id.SignedInUserId
15641        "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
15642    } else {
15643        "OfficeSignedInUserId: None"
15644    }
15645} else {
15646    "OfficeSignedInUserId: Not configured"
15647}
15648$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
15649if (Test-Path $teamsAcct) {
15650    $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
15651    $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
15652    if (-not [string]::IsNullOrWhiteSpace($email)) {
15653        $allAccounts += $email
15654        "TeamsAccount: $(MaskEmail $email)"
15655    } else {
15656        "TeamsAccount: Unknown"
15657    }
15658} else {
15659    "TeamsAccount: Not configured"
15660}
15661$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
15662$oneDriveEmails = @()
15663if (Test-Path $oneDriveBase) {
15664    $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
15665        ForEach-Object {
15666            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
15667            if ($p.UserEmail) { [string]$p.UserEmail }
15668        } |
15669        Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
15670        Sort-Object -Unique
15671}
15672$allAccounts += $oneDriveEmails
15673"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
15674if (@($oneDriveEmails).Count -gt 0) {
15675    "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15676}
15677$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
15678"DistinctIdentityCount: $($distinct.Count)"
15679if ($distinct.Count -gt 0) {
15680    "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
15681}
15682"#;
15683    match run_powershell(ps_accounts) {
15684        Ok(o) if !o.trim().is_empty() => {
15685            for line in o.lines().take(max_entries + 6) {
15686                let l = line.trim();
15687                if !l.is_empty() {
15688                    let _ = writeln!(out, "- {l}");
15689                }
15690            }
15691        }
15692        _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
15693    }
15694
15695    out.push_str("\n=== WebView2 auth dependency ===\n");
15696    let ps_webview = r#"
15697$paths = @(
15698    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
15699    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
15700) | Where-Object { $_ -and (Test-Path $_) }
15701$runtimeDir = $paths | ForEach-Object {
15702    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
15703        Where-Object { $_.Name -match '^\d+\.' } |
15704        Sort-Object Name -Descending |
15705        Select-Object -First 1
15706} | Select-Object -First 1
15707if ($runtimeDir) {
15708    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
15709    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
15710    "WebView2: Installed | Version: $version"
15711} else {
15712    "WebView2: Not installed"
15713}
15714"#;
15715    match run_powershell(ps_webview) {
15716        Ok(o) if !o.trim().is_empty() => {
15717            for line in o.lines().take(4) {
15718                let l = line.trim();
15719                if !l.is_empty() {
15720                    let _ = writeln!(out, "- {l}");
15721                }
15722            }
15723        }
15724        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
15725    }
15726
15727    out.push_str("\n=== Recent auth-related events (24h) ===\n");
15728    let ps_events = r#"
15729try {
15730    $cutoff = (Get-Date).AddHours(-24)
15731    $events = @()
15732    if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
15733        $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
15734            Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
15735            Select-Object -First 4
15736    }
15737    $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
15738        Where-Object {
15739            ($_.LevelDisplayName -in @('Error','Warning')) -and (
15740                $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
15741                -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
15742            )
15743        } |
15744        Select-Object -First 6
15745    $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
15746    "AuthEventCount: $(@($events).Count)"
15747    if ($events) {
15748        foreach ($e in $events) {
15749            $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
15750                'No message'
15751            } else {
15752                ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
15753            }
15754            "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
15755        }
15756    } else {
15757        "No auth-related warning/error events detected"
15758    }
15759} catch {
15760    "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
15761}
15762"#;
15763    match run_powershell(ps_events) {
15764        Ok(o) if !o.trim().is_empty() => {
15765            for line in o.lines().take(max_entries + 8) {
15766                let l = line.trim();
15767                if !l.is_empty() {
15768                    let _ = writeln!(out, "- {l}");
15769                }
15770            }
15771        }
15772        _ => out
15773            .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
15774    }
15775
15776    let parse_count = |prefix: &str| -> Option<u64> {
15777        out.lines().find_map(|line| {
15778            line.trim()
15779                .strip_prefix(prefix)
15780                .and_then(|value| value.trim().parse::<u64>().ok())
15781        })
15782    };
15783
15784    let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
15785    let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
15786
15787    let mut findings: Vec<String> = Vec::with_capacity(4);
15788    if out.contains("TokenBroker | Status: Stopped")
15789        || out.contains("wlidsvc | Status: Stopped")
15790        || out.contains("OneAuth | Status: Stopped")
15791    {
15792        findings.push(
15793            "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."
15794                .into(),
15795        );
15796    }
15797    if out.contains("AADBrokerPlugin: Not installed") {
15798        findings.push(
15799            "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
15800                .into(),
15801        );
15802    }
15803    if out.contains("WebView2: Not installed") {
15804        findings.push(
15805            "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
15806                .into(),
15807        );
15808    }
15809    if distinct_identity_count > 1 {
15810        findings.push(format!(
15811            "{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."
15812        ));
15813    }
15814    if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
15815        && distinct_identity_count > 0
15816    {
15817        findings.push(
15818            "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
15819                .into(),
15820        );
15821    }
15822    if out.contains("DeviceRegistration: dsregcmd")
15823        || out.contains("DeviceRegistration: Could not inspect device registration state")
15824    {
15825        findings.push(
15826            "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."
15827                .into(),
15828        );
15829    }
15830    if auth_event_count > 0 {
15831        findings.push(format!(
15832            "{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."
15833        ));
15834    } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
15835        findings.push(
15836            "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."
15837                .into(),
15838        );
15839    }
15840
15841    let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
15842    if findings.is_empty() {
15843        result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
15844    } else {
15845        for finding in &findings {
15846            let _ = writeln!(result, "- Finding: {finding}");
15847        }
15848    }
15849    result.push('\n');
15850    result.push_str(&out);
15851    Ok(result)
15852}
15853
15854#[cfg(not(windows))]
15855fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
15856    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())
15857}
15858
15859#[cfg(windows)]
15860fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
15861    let mut out = String::from("=== File History ===\n");
15862
15863    let ps_fh = r#"
15864$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
15865if ($svc) {
15866    "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
15867} else {
15868    "FileHistoryService: Not found"
15869}
15870# File History config in registry
15871$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
15872$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
15873if (Test-Path $fhUser) {
15874    $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
15875    $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
15876    $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
15877    $lastBackup = if ($fh.ProtectedUpToTime) {
15878        try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
15879    } else { 'Never' }
15880    "Enabled: $enabled"
15881    "BackupDrive: $target"
15882    "LastBackup: $lastBackup"
15883} else {
15884    "Enabled: Not configured"
15885    "BackupDrive: Not configured"
15886    "LastBackup: Never"
15887}
15888"#;
15889    match run_powershell(ps_fh) {
15890        Ok(o) if !o.trim().is_empty() => {
15891            for line in o.lines().take(6) {
15892                let l = line.trim();
15893                if !l.is_empty() {
15894                    let _ = writeln!(out, "- {l}");
15895                }
15896            }
15897        }
15898        _ => out.push_str("- Could not inspect File History state\n"),
15899    }
15900
15901    out.push_str("\n=== Windows Backup (wbadmin) ===\n");
15902    let ps_wbadmin = r#"
15903$svc = Get-Service wbengine -ErrorAction SilentlyContinue
15904"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
15905# Last backup from wbadmin
15906$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
15907if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
15908    $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
15909    $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
15910    if ($lastDate) { $lastDate.Trim() }
15911    if ($lastTarget) { $lastTarget.Trim() }
15912} else {
15913    "LastWbadminBackup: No backup versions found"
15914}
15915# Task-based backup
15916$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
15917foreach ($t in $task) {
15918    "BackupTask: $($t.TaskName) | State: $($t.State)"
15919}
15920"#;
15921    match run_powershell(ps_wbadmin) {
15922        Ok(o) if !o.trim().is_empty() => {
15923            for line in o.lines().take(8) {
15924                let l = line.trim();
15925                if !l.is_empty() {
15926                    let _ = writeln!(out, "- {l}");
15927                }
15928            }
15929        }
15930        _ => out.push_str("- Could not inspect Windows Backup state\n"),
15931    }
15932
15933    out.push_str("\n=== System Restore ===\n");
15934    let ps_sr = r#"
15935$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
15936    Select-Object -ExpandProperty DeviceID
15937foreach ($drive in $drives) {
15938    $protection = try {
15939        (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
15940    } catch { $null }
15941    $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
15942    $rpConf = try {
15943        Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
15944    } catch { $null }
15945    # Check if SR is disabled for this drive
15946    $disabled = $false
15947    $vssService = Get-Service VSS -ErrorAction SilentlyContinue
15948    "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
15949}
15950# Most recent restore point
15951$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
15952if ($points) {
15953    $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
15954    $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
15955    "MostRecentRestorePoint: $($latest.Description) | Created: $date"
15956} else {
15957    "MostRecentRestorePoint: None found"
15958}
15959$srEnabled = try {
15960    $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
15961    if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
15962} catch { 'Unknown' }
15963"SystemRestoreState: $srEnabled"
15964"#;
15965    match run_powershell(ps_sr) {
15966        Ok(o) if !o.trim().is_empty() => {
15967            for line in o.lines().take(8) {
15968                let l = line.trim();
15969                if !l.is_empty() {
15970                    let _ = writeln!(out, "- {l}");
15971                }
15972            }
15973        }
15974        _ => out.push_str("- Could not inspect System Restore state\n"),
15975    }
15976
15977    out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
15978    let ps_kfm = r#"
15979$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
15980if (Test-Path $kfmKey) {
15981    $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
15982    foreach ($acct in $accounts | Select-Object -First 3) {
15983        $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
15984        $email = $props.UserEmail
15985        $kfmDesktop = $props.'KFMSilentOptInDesktop'
15986        $kfmDocs = $props.'KFMSilentOptInDocuments'
15987        $kfmPics = $props.'KFMSilentOptInPictures'
15988        "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' })"
15989    }
15990} else {
15991    "OneDriveKFM: No OneDrive accounts found"
15992}
15993"#;
15994    match run_powershell(ps_kfm) {
15995        Ok(o) if !o.trim().is_empty() => {
15996            for line in o.lines().take(6) {
15997                let l = line.trim();
15998                if !l.is_empty() {
15999                    let _ = writeln!(out, "- {l}");
16000                }
16001            }
16002        }
16003        _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
16004    }
16005
16006    out.push_str("\n=== Recent backup failure events (7d) ===\n");
16007    let ps_events = r#"
16008$cutoff = (Get-Date).AddDays(-7)
16009$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
16010    Where-Object {
16011        $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
16012        ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
16013    } |
16014    Where-Object { $_.Level -le 3 } |
16015    Select-Object -First 6
16016if ($events) {
16017    foreach ($event in $events) {
16018        $msg = ($event.Message -replace '\s+', ' ')
16019        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
16020        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
16021    }
16022} else {
16023    "No recent backup failure events detected"
16024}
16025"#;
16026    match run_powershell(ps_events) {
16027        Ok(o) if !o.trim().is_empty() => {
16028            for line in o.lines().take(8) {
16029                let l = line.trim();
16030                if !l.is_empty() {
16031                    let _ = writeln!(out, "- {l}");
16032                }
16033            }
16034        }
16035        _ => out.push_str("- Could not inspect backup failure events\n"),
16036    }
16037
16038    let mut findings: Vec<String> = Vec::with_capacity(4);
16039
16040    let fh_enabled = out.contains("- Enabled: Enabled");
16041    let fh_never =
16042        out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
16043    let no_wbadmin = out.contains("No backup versions found");
16044    let no_restore_point = out.contains("MostRecentRestorePoint: None found");
16045
16046    if !fh_enabled && no_wbadmin {
16047        findings.push(
16048            "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(),
16049        );
16050    } else if fh_enabled && fh_never {
16051        findings.push(
16052            "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
16053        );
16054    }
16055
16056    if no_restore_point {
16057        findings.push(
16058            "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
16059        );
16060    }
16061
16062    if out.contains("- FileHistoryService: Stopped")
16063        || out.contains("- FileHistoryService: Not found")
16064    {
16065        findings.push(
16066            "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
16067        );
16068    }
16069
16070    if out.contains("Application Error |")
16071        || out.contains("Microsoft-Windows-Backup |")
16072        || out.contains("wbengine |")
16073    {
16074        findings.push(
16075            "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
16076        );
16077    }
16078
16079    let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
16080    if findings.is_empty() {
16081        result.push_str("- No obvious backup health blocker detected.\n");
16082    } else {
16083        for finding in &findings {
16084            let _ = writeln!(result, "- Finding: {finding}");
16085        }
16086    }
16087    result.push('\n');
16088    result.push_str(&out);
16089    Ok(result)
16090}
16091
16092#[cfg(not(windows))]
16093fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
16094    Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
16095}
16096
16097#[cfg(windows)]
16098fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
16099    let mut out = String::from("=== Windows Search service ===\n");
16100
16101    // Service state
16102    let ps_svc = r#"
16103$svc = Get-Service WSearch -ErrorAction SilentlyContinue
16104if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
16105else { "WSearch service not found" }
16106"#;
16107    match run_powershell(ps_svc) {
16108        Ok(o) => {
16109            let _ = writeln!(out, "- {}", o.trim());
16110        }
16111        Err(_) => out.push_str("- Could not query WSearch service\n"),
16112    }
16113
16114    // Indexer state via registry
16115    out.push_str("\n=== Indexer state ===\n");
16116    let ps_idx = r#"
16117$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
16118$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
16119if ($props) {
16120    "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
16121    "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
16122    "DataDirectory: $($props.DataDirectory)"
16123} else { "Registry key not found" }
16124"#;
16125    match run_powershell(ps_idx) {
16126        Ok(o) => {
16127            for line in o.lines() {
16128                let l = line.trim();
16129                if !l.is_empty() {
16130                    let _ = writeln!(out, "- {l}");
16131                }
16132            }
16133        }
16134        Err(_) => out.push_str("- Could not read indexer registry\n"),
16135    }
16136
16137    // Indexed locations
16138    out.push_str("\n=== Indexed locations ===\n");
16139    let ps_locs = r#"
16140$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
16141if ($comObj) {
16142    $catalog = $comObj.GetCatalog('SystemIndex')
16143    $manager = $catalog.GetCrawlScopeManager()
16144    $rules = $manager.EnumerateRoots()
16145    while ($true) {
16146        try {
16147            $root = $rules.Next(1)
16148            if ($root.Count -eq 0) { break }
16149            $r = $root[0]
16150            "  $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
16151        } catch { break }
16152    }
16153} else { "  COM admin interface not available (normal on non-admin sessions)" }
16154"#;
16155    match run_powershell(ps_locs) {
16156        Ok(o) if !o.trim().is_empty() => {
16157            for line in o.lines() {
16158                let l = line.trim_end();
16159                if !l.is_empty() {
16160                    let _ = writeln!(out, "{l}");
16161                }
16162            }
16163        }
16164        _ => {
16165            // Fallback: read from registry
16166            let ps_reg = r#"
16167Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
16168ForEach-Object { "  $($_.PSChildName)" } | Select-Object -First 20
16169"#;
16170            match run_powershell(ps_reg) {
16171                Ok(o) if !o.trim().is_empty() => {
16172                    for line in o.lines() {
16173                        let l = line.trim_end();
16174                        if !l.is_empty() {
16175                            let _ = writeln!(out, "{l}");
16176                        }
16177                    }
16178                }
16179                _ => out.push_str("  - Could not enumerate indexed locations\n"),
16180            }
16181        }
16182    }
16183
16184    // Recent indexing errors from event log
16185    out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
16186    let ps_evts = r#"
16187Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
16188Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
16189ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
16190"#;
16191    match run_powershell(ps_evts) {
16192        Ok(o) if !o.trim().is_empty() => {
16193            for line in o.lines() {
16194                let l = line.trim();
16195                if !l.is_empty() {
16196                    let _ = writeln!(out, "- {l}");
16197                }
16198            }
16199        }
16200        _ => out.push_str("- No recent indexer errors found\n"),
16201    }
16202
16203    let mut findings: Vec<String> = Vec::with_capacity(4);
16204    if out.contains("Status: Stopped") {
16205        findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
16206    }
16207    if out.contains("IsContentIndexingEnabled: 0")
16208        || out.contains("IsContentIndexingEnabled: False")
16209    {
16210        findings.push(
16211            "Content indexing is disabled — file content won't be searchable, only filenames."
16212                .into(),
16213        );
16214    }
16215    if out.contains("SetupCompletedSuccessfully: 0")
16216        || out.contains("SetupCompletedSuccessfully: False")
16217    {
16218        findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
16219    }
16220
16221    let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
16222    if findings.is_empty() {
16223        result.push_str("- Windows Search service and indexer appear healthy.\n");
16224        result.push_str("  If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
16225    } else {
16226        for f in &findings {
16227            let _ = writeln!(result, "- Finding: {f}");
16228        }
16229    }
16230    result.push('\n');
16231    result.push_str(&out);
16232    Ok(result)
16233}
16234
16235#[cfg(not(windows))]
16236fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
16237    Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
16238}
16239
16240// ── inspect_display_config ────────────────────────────────────────────────────
16241
16242#[cfg(windows)]
16243fn inspect_display_config(max_entries: usize) -> Result<String, String> {
16244    let mut out = String::with_capacity(1024);
16245
16246    // Active displays via CIM
16247    out.push_str("=== Active displays ===\n");
16248    let ps_displays = r#"
16249Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
16250Select-Object -First 20 |
16251ForEach-Object {
16252    "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
16253}
16254"#;
16255    match run_powershell(ps_displays) {
16256        Ok(o) if !o.trim().is_empty() => {
16257            for line in o.lines().take(max_entries) {
16258                let l = line.trim();
16259                if !l.is_empty() {
16260                    let _ = writeln!(out, "- {l}");
16261                }
16262            }
16263        }
16264        _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
16265    }
16266
16267    // GPU / video adapter
16268    out.push_str("\n=== Video adapters ===\n");
16269    let ps_gpu = r#"
16270Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
16271ForEach-Object {
16272    $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
16273    $hz  = "$($_.CurrentRefreshRate) Hz"
16274    $bits = "$($_.CurrentBitsPerPixel) bpp"
16275    "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
16276}
16277"#;
16278    match run_powershell(ps_gpu) {
16279        Ok(o) if !o.trim().is_empty() => {
16280            for line in o.lines().take(max_entries) {
16281                let l = line.trim();
16282                if !l.is_empty() {
16283                    let _ = writeln!(out, "- {l}");
16284                }
16285            }
16286        }
16287        _ => out.push_str("- Could not query video adapter info\n"),
16288    }
16289
16290    // Monitor names via Win32_DesktopMonitor
16291    out.push_str("\n=== Connected monitors ===\n");
16292    let ps_monitors = r#"
16293Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
16294ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
16295"#;
16296    match run_powershell(ps_monitors) {
16297        Ok(o) if !o.trim().is_empty() => {
16298            for line in o.lines().take(max_entries) {
16299                let l = line.trim();
16300                if !l.is_empty() {
16301                    let _ = writeln!(out, "- {l}");
16302                }
16303            }
16304        }
16305        _ => out.push_str("- No monitor info available via WMI\n"),
16306    }
16307
16308    // DPI scaling
16309    out.push_str("\n=== DPI / scaling ===\n");
16310    let ps_dpi = r#"
16311Add-Type -TypeDefinition @'
16312using System; using System.Runtime.InteropServices;
16313public class DPI {
16314    [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
16315    [DllImport("gdi32")]  public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
16316    [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
16317}
16318'@ -ErrorAction SilentlyContinue
16319try {
16320    $hdc  = [DPI]::GetDC([IntPtr]::Zero)
16321    $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
16322    $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
16323    [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
16324    $scale = [Math]::Round($dpiX / 96.0 * 100)
16325    "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
16326} catch { "DPI query unavailable" }
16327"#;
16328    match run_powershell(ps_dpi) {
16329        Ok(o) if !o.trim().is_empty() => {
16330            let _ = writeln!(out, "- {}", o.trim());
16331        }
16332        _ => out.push_str("- DPI info unavailable\n"),
16333    }
16334
16335    let mut findings: Vec<String> = Vec::with_capacity(4);
16336    if out.contains("0x0") || out.contains("@ 0 Hz") {
16337        findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
16338    }
16339
16340    let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
16341    if findings.is_empty() {
16342        result.push_str("- Display configuration appears normal.\n");
16343    } else {
16344        for f in &findings {
16345            let _ = writeln!(result, "- Finding: {f}");
16346        }
16347    }
16348    result.push('\n');
16349    result.push_str(&out);
16350    Ok(result)
16351}
16352
16353#[cfg(not(windows))]
16354fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
16355    Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
16356}
16357
16358// ── inspect_ntp ───────────────────────────────────────────────────────────────
16359
16360#[cfg(windows)]
16361fn inspect_ntp() -> Result<String, String> {
16362    let mut out = String::with_capacity(1024);
16363
16364    // w32tm status
16365    out.push_str("=== Windows Time service ===\n");
16366    let ps_svc = r#"
16367$svc = Get-Service W32Time -ErrorAction SilentlyContinue
16368if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
16369else { "W32Time service not found" }
16370"#;
16371    match run_powershell(ps_svc) {
16372        Ok(o) => {
16373            let _ = writeln!(out, "- {}", o.trim());
16374        }
16375        Err(_) => out.push_str("- Could not query W32Time service\n"),
16376    }
16377
16378    // NTP source and last sync
16379    out.push_str("\n=== NTP source and sync status ===\n");
16380    let ps_sync = r#"
16381$q = w32tm /query /status 2>$null
16382if ($q) { $q } else { "w32tm query unavailable" }
16383"#;
16384    match run_powershell(ps_sync) {
16385        Ok(o) if !o.trim().is_empty() => {
16386            for line in o.lines() {
16387                let l = line.trim();
16388                if !l.is_empty() {
16389                    let _ = writeln!(out, "  {l}");
16390                }
16391            }
16392        }
16393        _ => out.push_str("  - Could not query w32tm status\n"),
16394    }
16395
16396    // Configured NTP server
16397    out.push_str("\n=== Configured NTP servers ===\n");
16398    let ps_peers = r#"
16399w32tm /query /peers 2>$null | Select-Object -First 10
16400"#;
16401    match run_powershell(ps_peers) {
16402        Ok(o) if !o.trim().is_empty() => {
16403            for line in o.lines() {
16404                let l = line.trim();
16405                if !l.is_empty() {
16406                    let _ = writeln!(out, "  {l}");
16407                }
16408            }
16409        }
16410        _ => {
16411            // Fallback: registry
16412            let ps_reg = r#"
16413(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
16414"#;
16415            match run_powershell(ps_reg) {
16416                Ok(o) if !o.trim().is_empty() => {
16417                    let _ = writeln!(out, "  NtpServer (registry): {}", o.trim());
16418                }
16419                _ => out.push_str("  - Could not enumerate NTP peers\n"),
16420            }
16421        }
16422    }
16423
16424    let mut findings: Vec<String> = Vec::with_capacity(4);
16425    if out.contains("W32Time | Status: Stopped") {
16426        findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
16427    }
16428    if out.contains("The computer did not resync") || out.contains("Error") {
16429        findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
16430    }
16431
16432    let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
16433    if findings.is_empty() {
16434        result.push_str("- Windows Time service and NTP sync appear healthy.\n");
16435    } else {
16436        for f in &findings {
16437            let _ = writeln!(result, "- Finding: {f}");
16438        }
16439    }
16440    result.push('\n');
16441    result.push_str(&out);
16442    Ok(result)
16443}
16444
16445#[cfg(not(windows))]
16446fn inspect_ntp() -> Result<String, String> {
16447    // Linux/macOS: check timedatectl / chrony / ntpq
16448    let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
16449
16450    let timedatectl = std::process::Command::new("timedatectl")
16451        .arg("status")
16452        .output();
16453
16454    if let Ok(o) = timedatectl {
16455        let text = String::from_utf8_lossy(&o.stdout);
16456        if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
16457            out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
16458        } else {
16459            out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
16460        }
16461        for line in text.lines() {
16462            let l = line.trim();
16463            if !l.is_empty() {
16464                let _ = write!(out, "  {l}\n");
16465            }
16466        }
16467        return Ok(out);
16468    }
16469
16470    // macOS fallback
16471    let sntp = std::process::Command::new("sntp")
16472        .args(["-d", "time.apple.com"])
16473        .output();
16474    if let Ok(o) = sntp {
16475        out.push_str("- NTP check via sntp:\n");
16476        out.push_str(&String::from_utf8_lossy(&o.stdout));
16477        return Ok(out);
16478    }
16479
16480    out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
16481    Ok(out)
16482}
16483
16484// ── inspect_cpu_power ─────────────────────────────────────────────────────────
16485
16486#[cfg(windows)]
16487fn inspect_cpu_power() -> Result<String, String> {
16488    let mut out = String::with_capacity(1024);
16489
16490    // Active power plan
16491    out.push_str("=== Active power plan ===\n");
16492    let ps_plan = r#"
16493$plan = powercfg /getactivescheme 2>$null
16494if ($plan) { $plan } else { "Could not query power scheme" }
16495"#;
16496    match run_powershell(ps_plan) {
16497        Ok(o) if !o.trim().is_empty() => {
16498            let _ = writeln!(out, "- {}", o.trim());
16499        }
16500        _ => out.push_str("- Could not read active power plan\n"),
16501    }
16502
16503    // Processor min/max state and boost policy
16504    out.push_str("\n=== Processor performance policy ===\n");
16505    let ps_proc = r#"
16506$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
16507$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16508$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16509$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
16510if ($min)   { "Min processor state:  $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
16511if ($max)   { "Max processor state:  $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
16512if ($boost) {
16513    $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
16514    $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
16515    "Turbo boost mode:     $bname"
16516}
16517"#;
16518    match run_powershell(ps_proc) {
16519        Ok(o) if !o.trim().is_empty() => {
16520            for line in o.lines() {
16521                let l = line.trim();
16522                if !l.is_empty() {
16523                    let _ = writeln!(out, "- {l}");
16524                }
16525            }
16526        }
16527        _ => out.push_str("- Could not query processor performance settings\n"),
16528    }
16529
16530    // Current CPU frequency via WMI
16531    out.push_str("\n=== CPU frequency ===\n");
16532    let ps_freq = r#"
16533Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
16534ForEach-Object {
16535    $cur = $_.CurrentClockSpeed
16536    $max = $_.MaxClockSpeed
16537    $load = $_.LoadPercentage
16538    "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
16539}
16540"#;
16541    match run_powershell(ps_freq) {
16542        Ok(o) if !o.trim().is_empty() => {
16543            for line in o.lines() {
16544                let l = line.trim();
16545                if !l.is_empty() {
16546                    let _ = writeln!(out, "- {l}");
16547                }
16548            }
16549        }
16550        _ => out.push_str("- Could not query CPU frequency via WMI\n"),
16551    }
16552
16553    // Throttle reason from ETW (quick check)
16554    out.push_str("\n=== Throttling indicators ===\n");
16555    let ps_throttle = r#"
16556$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
16557if ($pwr) {
16558    $pwr | Select-Object -First 4 | ForEach-Object {
16559        $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
16560        "Thermal zone $($_.InstanceName): ${c}°C"
16561    }
16562} else { "Thermal zone WMI not available (normal on consumer hardware)" }
16563"#;
16564    match run_powershell(ps_throttle) {
16565        Ok(o) if !o.trim().is_empty() => {
16566            for line in o.lines() {
16567                let l = line.trim();
16568                if !l.is_empty() {
16569                    let _ = writeln!(out, "- {l}");
16570                }
16571            }
16572        }
16573        _ => out.push_str("- Thermal zone info unavailable\n"),
16574    }
16575
16576    let mut findings: Vec<String> = Vec::with_capacity(4);
16577    if out.contains("Max processor state:  0%") || out.contains("Max processor state:  1%") {
16578        findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
16579    }
16580    if out.contains("Turbo boost mode:     Disabled") {
16581        findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
16582    }
16583    if out.contains("Min processor state:  100%") {
16584        findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
16585    }
16586
16587    let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
16588    if findings.is_empty() {
16589        result.push_str("- CPU power and frequency settings appear normal.\n");
16590    } else {
16591        for f in &findings {
16592            let _ = writeln!(result, "- Finding: {f}");
16593        }
16594    }
16595    result.push('\n');
16596    result.push_str(&out);
16597    Ok(result)
16598}
16599
16600#[cfg(windows)]
16601fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16602    let mut out = String::with_capacity(1024);
16603
16604    out.push_str("=== Credential vault summary ===\n");
16605    let ps_summary = r#"
16606$raw = cmdkey /list 2>&1
16607$lines = $raw -split "`n"
16608$total = ($lines | Where-Object { $_ -match "Target:" }).Count
16609"Total stored credentials: $total"
16610$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
16611$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
16612$cert    = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
16613"  Windows credentials: $windows"
16614"  Generic credentials: $generic"
16615"  Certificate-based:   $cert"
16616"#;
16617    match run_powershell(ps_summary) {
16618        Ok(o) => {
16619            for line in o.lines() {
16620                let l = line.trim();
16621                if !l.is_empty() {
16622                    let _ = writeln!(out, "- {l}");
16623                }
16624            }
16625        }
16626        Err(e) => {
16627            let _ = writeln!(out, "- Credential summary error: {e}");
16628        }
16629    }
16630
16631    out.push_str("\n=== Credential targets (up to 20) ===\n");
16632    let ps_list = r#"
16633$raw = cmdkey /list 2>&1
16634$entries = @(); $cur = @{}
16635foreach ($line in ($raw -split "`n")) {
16636    $l = $line.Trim()
16637    if     ($l -match "^Target:\s*(.+)")  { $cur = @{ Target=$Matches[1] } }
16638    elseif ($l -match "^Type:\s*(.+)"   -and $cur.Target) { $cur.Type=$Matches[1] }
16639    elseif ($l -match "^User:\s*(.+)"   -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
16640}
16641$entries | Select-Object -Last 20 | ForEach-Object {
16642    "[$($_.Type)] $($_.Target)  (user: $($_.User))"
16643}
16644"#;
16645    match run_powershell(ps_list) {
16646        Ok(o) => {
16647            let lines: Vec<&str> = o
16648                .lines()
16649                .map(|l| l.trim())
16650                .filter(|l| !l.is_empty())
16651                .collect();
16652            if lines.is_empty() {
16653                out.push_str("- No credential entries found\n");
16654            } else {
16655                for l in &lines {
16656                    let _ = writeln!(out, "- {l}");
16657                }
16658            }
16659        }
16660        Err(e) => {
16661            let _ = writeln!(out, "- Credential list error: {e}");
16662        }
16663    }
16664
16665    let total_creds: usize = {
16666        let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
16667        run_powershell(ps_count)
16668            .ok()
16669            .and_then(|s| s.trim().parse().ok())
16670            .unwrap_or(0)
16671    };
16672
16673    let mut findings: Vec<String> = Vec::with_capacity(4);
16674    if total_creds > 30 {
16675        findings.push(format!(
16676            "{total_creds} stored credentials found — consider auditing for stale entries."
16677        ));
16678    }
16679
16680    let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
16681    if findings.is_empty() {
16682        result.push_str("- Credential store looks normal.\n");
16683    } else {
16684        for f in &findings {
16685            let _ = writeln!(result, "- Finding: {f}");
16686        }
16687    }
16688    result.push('\n');
16689    result.push_str(&out);
16690    Ok(result)
16691}
16692
16693#[cfg(not(windows))]
16694fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
16695    Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
16696}
16697
16698#[cfg(windows)]
16699fn inspect_tpm() -> Result<String, String> {
16700    let mut out = String::with_capacity(1024);
16701
16702    out.push_str("=== TPM state ===\n");
16703    let ps_tpm = r#"
16704function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
16705    $text = if ($null -eq $Value) { "" } else { [string]$Value }
16706    if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
16707    "$Name$text"
16708}
16709$t = Get-Tpm -ErrorAction SilentlyContinue
16710if ($t) {
16711    Emit-Field "TpmPresent:          " $t.TpmPresent
16712    Emit-Field "TpmReady:            " $t.TpmReady
16713    Emit-Field "TpmEnabled:          " $t.TpmEnabled
16714    Emit-Field "TpmOwned:            " $t.TpmOwned
16715    Emit-Field "RestartPending:      " $t.RestartPending
16716    Emit-Field "ManufacturerIdTxt:   " $t.ManufacturerIdTxt
16717    Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
16718} else { "TPM module unavailable" }
16719"#;
16720    match run_powershell(ps_tpm) {
16721        Ok(o) => {
16722            for line in o.lines() {
16723                let l = line.trim();
16724                if !l.is_empty() {
16725                    let _ = writeln!(out, "- {l}");
16726                }
16727            }
16728        }
16729        Err(e) => {
16730            let _ = writeln!(out, "- Get-Tpm error: {e}");
16731        }
16732    }
16733
16734    out.push_str("\n=== TPM spec version (WMI) ===\n");
16735    let ps_spec = r#"
16736$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
16737if ($wmi) {
16738    $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
16739    "SpecVersion:  $spec"
16740    "IsActivated:  $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
16741    "IsEnabled:    $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
16742    "IsOwned:      $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
16743} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
16744"#;
16745    match run_powershell(ps_spec) {
16746        Ok(o) => {
16747            for line in o.lines() {
16748                let l = line.trim();
16749                if !l.is_empty() {
16750                    let _ = writeln!(out, "- {l}");
16751                }
16752            }
16753        }
16754        Err(e) => {
16755            let _ = writeln!(out, "- Win32_Tpm WMI error: {e}");
16756        }
16757    }
16758
16759    out.push_str("\n=== Secure Boot state ===\n");
16760    let ps_sb = r#"
16761try {
16762    $sb = Confirm-SecureBootUEFI -ErrorAction Stop
16763    if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
16764} catch {
16765    $msg = $_.Exception.Message
16766    if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
16767        "Secure Boot: Unknown (administrator privileges required)"
16768    } elseif ($msg -match "Cmdlet not supported on this platform") {
16769        "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
16770    } else {
16771        "Secure Boot: N/A ($msg)"
16772    }
16773}
16774"#;
16775    match run_powershell(ps_sb) {
16776        Ok(o) => {
16777            for line in o.lines() {
16778                let l = line.trim();
16779                if !l.is_empty() {
16780                    let _ = writeln!(out, "- {l}");
16781                }
16782            }
16783        }
16784        Err(e) => {
16785            let _ = writeln!(out, "- Secure Boot check error: {e}");
16786        }
16787    }
16788
16789    out.push_str("\n=== Firmware type ===\n");
16790    let ps_fw = r#"
16791$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
16792switch ($fw) {
16793    1 { "Firmware type: BIOS (Legacy)" }
16794    2 { "Firmware type: UEFI" }
16795    default {
16796        $bcd = bcdedit /enum firmware 2>$null
16797        if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
16798        else { "Firmware type: Unknown or not set" }
16799    }
16800}
16801"#;
16802    match run_powershell(ps_fw) {
16803        Ok(o) => {
16804            for line in o.lines() {
16805                let l = line.trim();
16806                if !l.is_empty() {
16807                    let _ = writeln!(out, "- {l}");
16808                }
16809            }
16810        }
16811        Err(e) => {
16812            let _ = writeln!(out, "- Firmware type error: {e}");
16813        }
16814    }
16815
16816    let mut findings: Vec<String> = Vec::with_capacity(4);
16817    let mut indeterminate = false;
16818    if out.contains("TpmPresent:          False") {
16819        findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
16820    }
16821    if out.contains("TpmReady:            False") {
16822        findings.push(
16823            "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
16824        );
16825    }
16826    if out.contains("SpecVersion:  1.2") {
16827        findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
16828    }
16829    if out.contains("Secure Boot: DISABLED") {
16830        findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
16831    }
16832    if out.contains("Firmware type: BIOS (Legacy)") {
16833        findings.push(
16834            "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
16835        );
16836    }
16837
16838    if out.contains("TPM module unavailable")
16839        || out.contains("Win32_Tpm WMI class unavailable")
16840        || out.contains("Secure Boot: N/A")
16841        || out.contains("Secure Boot: Unknown")
16842        || out.contains("Firmware type: Unknown or not set")
16843        || out.contains("TpmPresent:          Unknown")
16844        || out.contains("TpmReady:            Unknown")
16845        || out.contains("TpmEnabled:          Unknown")
16846    {
16847        indeterminate = true;
16848    }
16849    if indeterminate {
16850        findings.push(
16851            "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
16852                .into(),
16853        );
16854    }
16855
16856    let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
16857    if findings.is_empty() {
16858        result.push_str("- TPM and Secure Boot appear healthy.\n");
16859    } else {
16860        for f in &findings {
16861            let _ = writeln!(result, "- Finding: {f}");
16862        }
16863    }
16864    result.push('\n');
16865    result.push_str(&out);
16866    Ok(result)
16867}
16868
16869#[cfg(not(windows))]
16870fn inspect_tpm() -> Result<String, String> {
16871    Ok(
16872        "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
16873            .into(),
16874    )
16875}
16876
16877#[cfg(windows)]
16878fn inspect_latency() -> Result<String, String> {
16879    let mut out = String::with_capacity(1024);
16880
16881    // Resolve default gateway from the routing table
16882    let ps_gw = r#"
16883$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
16884       Sort-Object RouteMetric | Select-Object -First 1).NextHop
16885if ($gw) { $gw } else { "" }
16886"#;
16887    let gateway = run_powershell(ps_gw)
16888        .ok()
16889        .map(|s| s.trim().to_string())
16890        .filter(|s| !s.is_empty());
16891
16892    let targets: Vec<(&str, String)> = {
16893        let mut t = Vec::with_capacity(3);
16894        if let Some(ref gw) = gateway {
16895            t.push(("Default gateway", gw.clone()));
16896        }
16897        t.push(("Cloudflare DNS", "1.1.1.1".into()));
16898        t.push(("Google DNS", "8.8.8.8".into()));
16899        t
16900    };
16901
16902    let mut findings: Vec<String> = Vec::with_capacity(4);
16903
16904    for (label, host) in &targets {
16905        let _ = write!(out, "\n=== Ping: {label} ({host}) ===\n");
16906        // Test-NetConnection gives RTT; -InformationLevel Quiet just returns bool, so use ping
16907        let ps_ping = format!(
16908            r#"
16909$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
16910if ($r) {{
16911    $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
16912    $min  = ($rtts | Measure-Object -Minimum).Minimum
16913    $max  = ($rtts | Measure-Object -Maximum).Maximum
16914    $avg  = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
16915    $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
16916    "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
16917    "Packet loss: ${{loss}}%"
16918    "Sent: 4  Received: $($r.Count)"
16919}} else {{
16920    "UNREACHABLE — 100% packet loss"
16921}}
16922"#
16923        );
16924        match run_powershell(&ps_ping) {
16925            Ok(o) => {
16926                let body = o.trim().to_string();
16927                for line in body.lines() {
16928                    let l = line.trim();
16929                    if !l.is_empty() {
16930                        let _ = writeln!(out, "- {l}");
16931                    }
16932                }
16933                if body.contains("UNREACHABLE") {
16934                    findings.push(format!(
16935                        "{label} ({host}) is unreachable — possible routing or firewall issue."
16936                    ));
16937                } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
16938                    let pct: u32 = loss_line
16939                        .chars()
16940                        .filter(|c| c.is_ascii_digit())
16941                        .collect::<String>()
16942                        .parse()
16943                        .unwrap_or(0);
16944                    if pct >= 25 {
16945                        findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
16946                    }
16947                    // High latency check
16948                    if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
16949                        // parse avg from "RTT min/avg/max: Xms / Yms / Zms"
16950                        if let Some(avg_field) = rtt_line.split('/').nth(1) {
16951                            let avg_str: String =
16952                                avg_field.chars().filter(|c| c.is_ascii_digit()).collect();
16953                            let avg: u32 = avg_str.parse().unwrap_or(0);
16954                            if avg > 150 {
16955                                findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
16956                            }
16957                        }
16958                    }
16959                }
16960            }
16961            Err(e) => {
16962                let _ = writeln!(out, "- Ping error: {e}");
16963            }
16964        }
16965    }
16966
16967    let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
16968    if findings.is_empty() {
16969        result.push_str("- Latency and reachability look normal.\n");
16970    } else {
16971        for f in &findings {
16972            let _ = writeln!(result, "- Finding: {f}");
16973        }
16974    }
16975    result.push('\n');
16976    result.push_str(&out);
16977    Ok(result)
16978}
16979
16980#[cfg(not(windows))]
16981fn inspect_latency() -> Result<String, String> {
16982    let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
16983    let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
16984    let mut findings: Vec<String> = Vec::with_capacity(4);
16985
16986    for (label, host) in &targets {
16987        let _ = write!(out, "\n=== Ping: {label} ({host}) ===\n");
16988        let ping = std::process::Command::new("ping")
16989            .args(["-c", "4", "-W", "2", host])
16990            .output();
16991        match ping {
16992            Ok(o) => {
16993                let body = String::from_utf8_lossy(&o.stdout).into_owned();
16994                for line in body.lines() {
16995                    let l = line.trim();
16996                    if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
16997                        let _ = write!(out, "- {l}\n");
16998                    }
16999                }
17000                if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
17001                    findings.push(format!("{label} ({host}) is unreachable."));
17002                }
17003            }
17004            Err(e) => {
17005                let _ = write!(out, "- ping error: {e}\n");
17006            }
17007        }
17008    }
17009
17010    if findings.is_empty() {
17011        out.insert_str(
17012            "Host inspection: latency\n\n=== Findings ===\n".len(),
17013            "- Latency and reachability look normal.\n",
17014        );
17015    } else {
17016        let mut prefix = String::new();
17017        for f in &findings {
17018            let _ = write!(prefix, "- Finding: {f}\n");
17019        }
17020        out.insert_str(
17021            "Host inspection: latency\n\n=== Findings ===\n".len(),
17022            &prefix,
17023        );
17024    }
17025    Ok(out)
17026}
17027
17028#[cfg(windows)]
17029fn inspect_network_adapter() -> Result<String, String> {
17030    let mut out = String::with_capacity(1024);
17031
17032    out.push_str("=== Network adapters ===\n");
17033    let ps_adapters = r#"
17034Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
17035    $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
17036    "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
17037}
17038"#;
17039    match run_powershell(ps_adapters) {
17040        Ok(o) => {
17041            for line in o.lines() {
17042                let l = line.trim();
17043                if !l.is_empty() {
17044                    let _ = writeln!(out, "- {l}");
17045                }
17046            }
17047        }
17048        Err(e) => {
17049            let _ = writeln!(out, "- Adapter query error: {e}");
17050        }
17051    }
17052
17053    out.push_str("\n=== Duplex and negotiated speed ===\n");
17054    let ps_duplex = r#"
17055Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
17056    $name = $_.Name
17057    $duplex = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
17058        Where-Object { $_.DisplayName -match "Duplex|Speed" } |
17059        Select-Object DisplayName, DisplayValue
17060    if ($duplex) {
17061        "--- $name ---"
17062        $duplex | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
17063    } else {
17064        "--- $name --- (no duplex/speed property exposed by driver)"
17065    }
17066}
17067"#;
17068    match run_powershell(ps_duplex) {
17069        Ok(o) => {
17070            let lines: Vec<&str> = o
17071                .lines()
17072                .map(|l| l.trim())
17073                .filter(|l| !l.is_empty())
17074                .collect();
17075            for l in &lines {
17076                let _ = writeln!(out, "- {l}");
17077            }
17078        }
17079        Err(e) => {
17080            let _ = writeln!(out, "- Duplex query error: {e}");
17081        }
17082    }
17083
17084    out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
17085    let ps_offload = r#"
17086Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
17087    $name = $_.Name
17088    $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
17089        Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
17090        Select-Object DisplayName, DisplayValue
17091    if ($props) {
17092        "--- $name ---"
17093        $props | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
17094    }
17095}
17096"#;
17097    match run_powershell(ps_offload) {
17098        Ok(o) => {
17099            let lines: Vec<&str> = o
17100                .lines()
17101                .map(|l| l.trim())
17102                .filter(|l| !l.is_empty())
17103                .collect();
17104            if lines.is_empty() {
17105                out.push_str(
17106                    "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
17107                );
17108            } else {
17109                for l in &lines {
17110                    let _ = writeln!(out, "- {l}");
17111                }
17112            }
17113        }
17114        Err(e) => {
17115            let _ = writeln!(out, "- Offload query error: {e}");
17116        }
17117    }
17118
17119    out.push_str("\n=== Adapter error counters ===\n");
17120    let ps_errors = r#"
17121Get-NetAdapterStatistics | ForEach-Object {
17122    $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
17123    if ($errs -gt 0) {
17124        "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
17125    }
17126}
17127"#;
17128    match run_powershell(ps_errors) {
17129        Ok(o) => {
17130            let lines: Vec<&str> = o
17131                .lines()
17132                .map(|l| l.trim())
17133                .filter(|l| !l.is_empty())
17134                .collect();
17135            if lines.is_empty() {
17136                out.push_str("- No adapter errors or discards detected.\n");
17137            } else {
17138                for l in &lines {
17139                    let _ = writeln!(out, "- {l}");
17140                }
17141            }
17142        }
17143        Err(e) => {
17144            let _ = writeln!(out, "- Error counter query: {e}");
17145        }
17146    }
17147
17148    out.push_str("\n=== Wake-on-LAN and power settings ===\n");
17149    let ps_wol = r#"
17150Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
17151    $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
17152    if ($wol) {
17153        "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
17154    }
17155}
17156"#;
17157    match run_powershell(ps_wol) {
17158        Ok(o) => {
17159            let lines: Vec<&str> = o
17160                .lines()
17161                .map(|l| l.trim())
17162                .filter(|l| !l.is_empty())
17163                .collect();
17164            if lines.is_empty() {
17165                out.push_str("- Power management data unavailable for active adapters.\n");
17166            } else {
17167                for l in &lines {
17168                    let _ = writeln!(out, "- {l}");
17169                }
17170            }
17171        }
17172        Err(e) => {
17173            let _ = writeln!(out, "- WoL query error: {e}");
17174        }
17175    }
17176
17177    let mut findings: Vec<String> = Vec::with_capacity(4);
17178    // Check for error-prone adapters
17179    if out.contains("RX errors:") || out.contains("TX errors:") {
17180        findings
17181            .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
17182    }
17183    // Check for half-duplex (rare but still seen on older switches)
17184    if out.contains("Half") {
17185        findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
17186    }
17187
17188    let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
17189    if findings.is_empty() {
17190        result.push_str("- Network adapter configuration looks normal.\n");
17191    } else {
17192        for f in &findings {
17193            let _ = writeln!(result, "- Finding: {f}");
17194        }
17195    }
17196    result.push('\n');
17197    result.push_str(&out);
17198    Ok(result)
17199}
17200
17201#[cfg(not(windows))]
17202fn inspect_network_adapter() -> Result<String, String> {
17203    let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
17204
17205    out.push_str("=== Network adapters (ip link) ===\n");
17206    let ip_link = std::process::Command::new("ip")
17207        .args(["link", "show"])
17208        .output();
17209    if let Ok(o) = ip_link {
17210        for line in String::from_utf8_lossy(&o.stdout).lines() {
17211            let l = line.trim();
17212            if !l.is_empty() {
17213                let _ = write!(out, "- {l}\n");
17214            }
17215        }
17216    }
17217
17218    out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
17219    let ip_stats = std::process::Command::new("ip")
17220        .args(["-s", "link", "show"])
17221        .output();
17222    if let Ok(o) = ip_stats {
17223        for line in String::from_utf8_lossy(&o.stdout).lines() {
17224            let l = line.trim();
17225            if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
17226            {
17227                let _ = write!(out, "- {l}\n");
17228            }
17229        }
17230    }
17231    Ok(out)
17232}
17233
17234#[cfg(windows)]
17235fn inspect_dhcp() -> Result<String, String> {
17236    let mut out = String::with_capacity(1024);
17237
17238    out.push_str("=== DHCP lease details (per adapter) ===\n");
17239    let ps_dhcp = r#"
17240$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17241    Where-Object { $_.IPEnabled -eq $true }
17242foreach ($a in $adapters) {
17243    "--- $($a.Description) ---"
17244    "  DHCP Enabled:      $($a.DHCPEnabled)"
17245    if ($a.DHCPEnabled) {
17246        "  DHCP Server:       $($a.DHCPServer)"
17247        $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
17248        $expires  = $a.ConvertToDateTime($a.DHCPLeaseExpires)  2>$null
17249        "  Lease Obtained:    $obtained"
17250        "  Lease Expires:     $expires"
17251    }
17252    "  IP Address:        $($a.IPAddress -join ', ')"
17253    "  Subnet Mask:       $($a.IPSubnet -join ', ')"
17254    "  Default Gateway:   $($a.DefaultIPGateway -join ', ')"
17255    "  DNS Servers:       $($a.DNSServerSearchOrder -join ', ')"
17256    "  MAC Address:       $($a.MACAddress)"
17257    ""
17258}
17259"#;
17260    match run_powershell(ps_dhcp) {
17261        Ok(o) => {
17262            for line in o.lines() {
17263                let l = line.trim_end();
17264                if !l.is_empty() {
17265                    let _ = writeln!(out, "{l}");
17266                }
17267            }
17268        }
17269        Err(e) => {
17270            let _ = writeln!(out, "- DHCP query error: {e}");
17271        }
17272    }
17273
17274    // Findings: check for expired or very-soon-expiring leases
17275    let mut findings: Vec<String> = Vec::with_capacity(4);
17276    let ps_expiry = r#"
17277$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
17278foreach ($a in $adapters) {
17279    try {
17280        $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
17281        $now = Get-Date
17282        $hrs = ($exp - $now).TotalHours
17283        if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
17284        elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
17285    } catch {}
17286}
17287"#;
17288    if let Ok(o) = run_powershell(ps_expiry) {
17289        for line in o.lines() {
17290            let l = line.trim();
17291            if !l.is_empty() {
17292                if l.contains("EXPIRED") {
17293                    findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
17294                } else if l.contains("expires in") {
17295                    findings.push(format!("DHCP lease expiring soon — {l}"));
17296                }
17297            }
17298        }
17299    }
17300
17301    let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
17302    if findings.is_empty() {
17303        result.push_str("- DHCP leases look healthy.\n");
17304    } else {
17305        for f in &findings {
17306            let _ = writeln!(result, "- Finding: {f}");
17307        }
17308    }
17309    result.push('\n');
17310    result.push_str(&out);
17311    Ok(result)
17312}
17313
17314#[cfg(not(windows))]
17315fn inspect_dhcp() -> Result<String, String> {
17316    let mut out = String::from(
17317        "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
17318    );
17319    out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
17320    for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
17321        if std::path::Path::new(path).exists() {
17322            let cat = std::process::Command::new("cat").arg(path).output();
17323            if let Ok(o) = cat {
17324                let text = String::from_utf8_lossy(&o.stdout);
17325                for line in text.lines().take(40) {
17326                    let l = line.trim();
17327                    if l.contains("lease")
17328                        || l.contains("expire")
17329                        || l.contains("server")
17330                        || l.contains("address")
17331                    {
17332                        let _ = write!(out, "- {l}\n");
17333                    }
17334                }
17335            }
17336        }
17337    }
17338    // Also try ip addr for current IPs
17339    let ip = std::process::Command::new("ip")
17340        .args(["addr", "show"])
17341        .output();
17342    if let Ok(o) = ip {
17343        out.push_str("\n=== Current IP addresses (ip addr) ===\n");
17344        for line in String::from_utf8_lossy(&o.stdout).lines() {
17345            let l = line.trim();
17346            if l.starts_with("inet") || l.contains("dynamic") {
17347                let _ = write!(out, "- {l}\n");
17348            }
17349        }
17350    }
17351    Ok(out)
17352}
17353
17354#[cfg(windows)]
17355fn inspect_mtu() -> Result<String, String> {
17356    let mut out = String::with_capacity(1024);
17357
17358    out.push_str("=== Per-adapter MTU (IPv4) ===\n");
17359    let ps_mtu = r#"
17360Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
17361    Sort-Object ConnectionState, InterfaceAlias |
17362    ForEach-Object {
17363        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
17364    }
17365"#;
17366    match run_powershell(ps_mtu) {
17367        Ok(o) => {
17368            for line in o.lines() {
17369                let l = line.trim();
17370                if !l.is_empty() {
17371                    let _ = writeln!(out, "- {l}");
17372                }
17373            }
17374        }
17375        Err(e) => {
17376            let _ = writeln!(out, "- MTU query error: {e}");
17377        }
17378    }
17379
17380    out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
17381    let ps_mtu6 = r#"
17382Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
17383    Sort-Object ConnectionState, InterfaceAlias |
17384    ForEach-Object {
17385        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
17386    }
17387"#;
17388    match run_powershell(ps_mtu6) {
17389        Ok(o) => {
17390            for line in o.lines() {
17391                let l = line.trim();
17392                if !l.is_empty() {
17393                    let _ = writeln!(out, "- {l}");
17394                }
17395            }
17396        }
17397        Err(e) => {
17398            let _ = writeln!(out, "- IPv6 MTU query error: {e}");
17399        }
17400    }
17401
17402    out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
17403    // Send a 1472-byte payload (1500 - 28 IP+ICMP headers) to test standard Ethernet MTU
17404    let ps_pmtu = r#"
17405$sizes = @(1472, 1400, 1280, 576)
17406$result = $null
17407foreach ($s in $sizes) {
17408    $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
17409    if ($r) { $result = $s; break }
17410}
17411if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
17412else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
17413"#;
17414    match run_powershell(ps_pmtu) {
17415        Ok(o) => {
17416            for line in o.lines() {
17417                let l = line.trim();
17418                if !l.is_empty() {
17419                    let _ = writeln!(out, "- {l}");
17420                }
17421            }
17422        }
17423        Err(e) => {
17424            let _ = writeln!(out, "- Path MTU test error: {e}");
17425        }
17426    }
17427
17428    let mut findings: Vec<String> = Vec::with_capacity(4);
17429    if out.contains("MTU: 576 bytes") {
17430        findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
17431    }
17432    if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
17433        findings.push(
17434            "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
17435                .into(),
17436        );
17437    }
17438    if out.contains("All test sizes failed") {
17439        findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
17440    }
17441
17442    let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
17443    if findings.is_empty() {
17444        result.push_str("- MTU configuration looks normal.\n");
17445    } else {
17446        for f in &findings {
17447            let _ = writeln!(result, "- Finding: {f}");
17448        }
17449    }
17450    result.push('\n');
17451    result.push_str(&out);
17452    Ok(result)
17453}
17454
17455#[cfg(not(windows))]
17456fn inspect_mtu() -> Result<String, String> {
17457    let mut out = String::from(
17458        "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
17459    );
17460
17461    out.push_str("=== Per-interface MTU (ip link) ===\n");
17462    let ip = std::process::Command::new("ip")
17463        .args(["link", "show"])
17464        .output();
17465    if let Ok(o) = ip {
17466        for line in String::from_utf8_lossy(&o.stdout).lines() {
17467            let l = line.trim();
17468            if l.contains("mtu") || l.starts_with("\\d") {
17469                let _ = write!(out, "- {l}\n");
17470            }
17471        }
17472    }
17473
17474    out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
17475    let ping = std::process::Command::new("ping")
17476        .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
17477        .output();
17478    match ping {
17479        Ok(o) => {
17480            let body = String::from_utf8_lossy(&o.stdout);
17481            for line in body.lines() {
17482                let l = line.trim();
17483                if !l.is_empty() {
17484                    let _ = write!(out, "- {l}\n");
17485                }
17486            }
17487        }
17488        Err(e) => {
17489            let _ = write!(out, "- Ping error: {e}\n");
17490        }
17491    }
17492    Ok(out)
17493}
17494
17495#[cfg(not(windows))]
17496fn inspect_cpu_power() -> Result<String, String> {
17497    let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
17498
17499    // Linux: cpufreq-info or /sys/devices/system/cpu
17500    out.push_str("=== CPU frequency (Linux) ===\n");
17501    let cat_scaling = std::process::Command::new("cat")
17502        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
17503        .output();
17504    if let Ok(o) = cat_scaling {
17505        let khz: u64 = String::from_utf8_lossy(&o.stdout)
17506            .trim()
17507            .parse()
17508            .unwrap_or(0);
17509        if khz > 0 {
17510            let _ = write!(out, "- Current: {} MHz\n", khz / 1000);
17511        }
17512    }
17513    let cat_max = std::process::Command::new("cat")
17514        .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
17515        .output();
17516    if let Ok(o) = cat_max {
17517        let khz: u64 = String::from_utf8_lossy(&o.stdout)
17518            .trim()
17519            .parse()
17520            .unwrap_or(0);
17521        if khz > 0 {
17522            let _ = write!(out, "- Max: {} MHz\n", khz / 1000);
17523        }
17524    }
17525    let governor = std::process::Command::new("cat")
17526        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
17527        .output();
17528    if let Ok(o) = governor {
17529        let g = String::from_utf8_lossy(&o.stdout);
17530        let g = g.trim();
17531        if !g.is_empty() {
17532            let _ = write!(out, "- Governor: {g}\n");
17533        }
17534    }
17535    Ok(out)
17536}
17537
17538// ── IPv6 ────────────────────────────────────────────────────────────────────
17539
17540#[cfg(windows)]
17541fn inspect_ipv6() -> Result<String, String> {
17542    let script = r#"
17543$result = [System.Text.StringBuilder]::new()
17544
17545# Per-adapter IPv6 addresses
17546$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
17547$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17548    Where-Object { $_.IPAddress -notmatch '^::1$' } |
17549    Sort-Object InterfaceAlias
17550foreach ($a in $adapters) {
17551    $prefix = $a.PrefixOrigin
17552    $suffix = $a.SuffixOrigin
17553    $scope  = $a.AddressState
17554    $result.AppendLine("  [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength)  origin=$prefix/$suffix  state=$scope") | Out-Null
17555}
17556if (-not $adapters) { $result.AppendLine("  No global/link-local IPv6 addresses found.") | Out-Null }
17557
17558# Default gateway IPv6
17559$result.AppendLine("") | Out-Null
17560$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
17561$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
17562if ($gw6) {
17563    foreach ($g in $gw6) {
17564        $result.AppendLine("  [$($g.InterfaceAlias)] via $($g.NextHop)  metric=$($g.RouteMetric)") | Out-Null
17565    }
17566} else {
17567    $result.AppendLine("  No IPv6 default gateway configured.") | Out-Null
17568}
17569
17570# DHCPv6 lease info
17571$result.AppendLine("") | Out-Null
17572$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
17573$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17574    Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
17575if ($dhcpv6) {
17576    foreach ($d in $dhcpv6) {
17577        $result.AppendLine("  [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
17578    }
17579} else {
17580    $result.AppendLine("  No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
17581}
17582
17583# Privacy extensions
17584$result.AppendLine("") | Out-Null
17585$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
17586try {
17587    $priv = netsh interface ipv6 show privacy
17588    $result.AppendLine(($priv -join "`n")) | Out-Null
17589} catch {
17590    $result.AppendLine("  Could not retrieve privacy extension state.") | Out-Null
17591}
17592
17593# Tunnel adapters
17594$result.AppendLine("") | Out-Null
17595$result.AppendLine("=== Tunnel adapters ===") | Out-Null
17596$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
17597if ($tunnels) {
17598    foreach ($t in $tunnels) {
17599        $result.AppendLine("  $($t.Name): $($t.InterfaceDescription)  Status=$($t.Status)") | Out-Null
17600    }
17601} else {
17602    $result.AppendLine("  No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
17603}
17604
17605# Findings
17606$findings = [System.Collections.Generic.List[string]]::new()
17607$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
17608    Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
17609if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
17610$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
17611if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
17612
17613$result.AppendLine("") | Out-Null
17614$result.AppendLine("=== Findings ===") | Out-Null
17615if ($findings.Count -eq 0) {
17616    $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
17617} else {
17618    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17619}
17620
17621Write-Output $result.ToString()
17622"#;
17623    let out = run_powershell(script)?;
17624    Ok(format!("Host inspection: ipv6\n\n{out}"))
17625}
17626
17627#[cfg(not(windows))]
17628fn inspect_ipv6() -> Result<String, String> {
17629    let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
17630    if let Ok(o) = std::process::Command::new("ip")
17631        .args(["-6", "addr", "show"])
17632        .output()
17633    {
17634        out.push_str(&String::from_utf8_lossy(&o.stdout));
17635    }
17636    out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
17637    if let Ok(o) = std::process::Command::new("ip")
17638        .args(["-6", "route"])
17639        .output()
17640    {
17641        out.push_str(&String::from_utf8_lossy(&o.stdout));
17642    }
17643    Ok(out)
17644}
17645
17646// ── TCP Parameters ──────────────────────────────────────────────────────────
17647
17648#[cfg(windows)]
17649fn inspect_tcp_params() -> Result<String, String> {
17650    let script = r#"
17651$result = [System.Text.StringBuilder]::new()
17652
17653# Autotuning and global TCP settings
17654$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
17655try {
17656    $global = netsh interface tcp show global
17657    foreach ($line in $global) {
17658        $l = $line.Trim()
17659        if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
17660            $result.AppendLine("  $l") | Out-Null
17661        }
17662    }
17663} catch {
17664    $result.AppendLine("  Could not retrieve TCP global settings.") | Out-Null
17665}
17666
17667# Supplemental params via Get-NetTCPSetting
17668$result.AppendLine("") | Out-Null
17669$result.AppendLine("=== TCP settings profiles ===") | Out-Null
17670try {
17671    $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
17672    foreach ($s in $tcpSettings) {
17673        $result.AppendLine("  Profile: $($s.SettingName)") | Out-Null
17674        $result.AppendLine("    CongestionProvider:      $($s.CongestionProvider)") | Out-Null
17675        $result.AppendLine("    InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
17676        $result.AppendLine("    AutoTuningLevelLocal:    $($s.AutoTuningLevelLocal)") | Out-Null
17677        $result.AppendLine("    ScalingHeuristics:       $($s.ScalingHeuristics)") | Out-Null
17678        $result.AppendLine("    DynamicPortRangeStart:   $($s.DynamicPortRangeStartPort)") | Out-Null
17679        $result.AppendLine("    DynamicPortRangeEnd:     $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
17680        $result.AppendLine("") | Out-Null
17681    }
17682} catch {
17683    $result.AppendLine("  Get-NetTCPSetting unavailable.") | Out-Null
17684}
17685
17686# Chimney offload state
17687$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
17688try {
17689    $chimney = netsh interface tcp show chimney
17690    $result.AppendLine(($chimney -join "`n  ")) | Out-Null
17691} catch {
17692    $result.AppendLine("  Could not retrieve chimney state.") | Out-Null
17693}
17694
17695# ECN state
17696$result.AppendLine("") | Out-Null
17697$result.AppendLine("=== ECN capability ===") | Out-Null
17698try {
17699    $ecn = netsh interface tcp show ecncapability
17700    $result.AppendLine(($ecn -join "`n  ")) | Out-Null
17701} catch {
17702    $result.AppendLine("  Could not retrieve ECN state.") | Out-Null
17703}
17704
17705# Findings
17706$findings = [System.Collections.Generic.List[string]]::new()
17707try {
17708    $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
17709    if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
17710        $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
17711    }
17712    if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
17713        $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
17714    }
17715} catch {}
17716
17717$result.AppendLine("") | Out-Null
17718$result.AppendLine("=== Findings ===") | Out-Null
17719if ($findings.Count -eq 0) {
17720    $result.AppendLine("- TCP parameters look normal.") | Out-Null
17721} else {
17722    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17723}
17724
17725Write-Output $result.ToString()
17726"#;
17727    let out = run_powershell(script)?;
17728    Ok(format!("Host inspection: tcp_params\n\n{out}"))
17729}
17730
17731#[cfg(not(windows))]
17732fn inspect_tcp_params() -> Result<String, String> {
17733    let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
17734    for key in &[
17735        "net.ipv4.tcp_congestion_control",
17736        "net.ipv4.tcp_rmem",
17737        "net.ipv4.tcp_wmem",
17738        "net.ipv4.tcp_window_scaling",
17739        "net.ipv4.tcp_ecn",
17740        "net.ipv4.tcp_timestamps",
17741    ] {
17742        if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
17743            let _ = write!(out, "  {}\n", String::from_utf8_lossy(&o.stdout).trim());
17744        }
17745    }
17746    Ok(out)
17747}
17748
17749// ── WLAN Profiles ───────────────────────────────────────────────────────────
17750
17751#[cfg(windows)]
17752fn inspect_wlan_profiles() -> Result<String, String> {
17753    let script = r#"
17754$result = [System.Text.StringBuilder]::new()
17755
17756# List all saved profiles
17757$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
17758try {
17759    $profilesRaw = netsh wlan show profiles
17760    $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17761        $_.Matches[0].Groups[1].Value.Trim()
17762    }
17763
17764    if (-not $profiles) {
17765        $result.AppendLine("  No saved wireless profiles found.") | Out-Null
17766    } else {
17767        foreach ($p in $profiles) {
17768            $result.AppendLine("") | Out-Null
17769            $result.AppendLine("  Profile: $p") | Out-Null
17770            # Get detail for each profile
17771            $detail = netsh wlan show profile name="$p" key=clear 2>$null
17772            $auth      = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17773            $cipher    = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
17774            $conn      = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
17775            $autoConn  = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
17776            if ($auth)     { $result.AppendLine("    Authentication:    $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17777            if ($cipher)   { $result.AppendLine("    Cipher:            $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17778            if ($conn)     { $result.AppendLine("    Connection mode:   $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17779            if ($autoConn) { $result.AppendLine("    Auto-connect:      $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17780        }
17781    }
17782} catch {
17783    $result.AppendLine("  netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
17784}
17785
17786# Currently connected SSID
17787$result.AppendLine("") | Out-Null
17788$result.AppendLine("=== Currently connected ===") | Out-Null
17789try {
17790    $conn = netsh wlan show interfaces
17791    $ssid   = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
17792    $bssid  = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
17793    $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
17794    $radio  = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
17795    if ($ssid)   { $result.AppendLine("  SSID:       $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17796    if ($bssid)  { $result.AppendLine("  BSSID:      $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17797    if ($signal) { $result.AppendLine("  Signal:     $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17798    if ($radio)  { $result.AppendLine("  Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
17799    if (-not $ssid) { $result.AppendLine("  Not connected to any wireless network.") | Out-Null }
17800} catch {
17801    $result.AppendLine("  Could not query wireless interface state.") | Out-Null
17802}
17803
17804# Findings
17805$findings = [System.Collections.Generic.List[string]]::new()
17806try {
17807    $allDetail = netsh wlan show profiles 2>$null
17808    $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
17809        $_.Matches[0].Groups[1].Value.Trim()
17810    }
17811    foreach ($pn in $profileNames) {
17812        $det = netsh wlan show profile name="$pn" key=clear 2>$null
17813        $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
17814        if ($authLine) {
17815            $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
17816            if ($authVal -match 'Open|WEP|None') {
17817                $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
17818            }
17819        }
17820    }
17821} catch {}
17822
17823$result.AppendLine("") | Out-Null
17824$result.AppendLine("=== Findings ===") | Out-Null
17825if ($findings.Count -eq 0) {
17826    $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | 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: wlan_profiles\n\n{out}"))
17835}
17836
17837#[cfg(not(windows))]
17838fn inspect_wlan_profiles() -> Result<String, String> {
17839    let mut out =
17840        String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
17841    // Try nmcli (NetworkManager)
17842    if let Ok(o) = std::process::Command::new("nmcli")
17843        .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
17844        .output()
17845    {
17846        for line in String::from_utf8_lossy(&o.stdout).lines() {
17847            if line.contains("wireless") || line.contains("wifi") {
17848                let _ = write!(out, "  {line}\n");
17849            }
17850        }
17851    } else {
17852        out.push_str("  nmcli not available.\n");
17853    }
17854    Ok(out)
17855}
17856
17857// ── IPSec ───────────────────────────────────────────────────────────────────
17858
17859#[cfg(windows)]
17860fn inspect_ipsec() -> Result<String, String> {
17861    let script = r#"
17862$result = [System.Text.StringBuilder]::new()
17863
17864# IPSec rules (firewall-integrated)
17865$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
17866try {
17867    $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
17868    if ($rules) {
17869        foreach ($r in $rules) {
17870            $result.AppendLine("  [$($r.DisplayName)]") | Out-Null
17871            $result.AppendLine("    Mode:       $($r.Mode)") | Out-Null
17872            $result.AppendLine("    Action:     $($r.Action)") | Out-Null
17873            $result.AppendLine("    InProfile:  $($r.Profile)") | Out-Null
17874        }
17875    } else {
17876        $result.AppendLine("  No enabled IPSec connection security rules found.") | Out-Null
17877    }
17878} catch {
17879    $result.AppendLine("  Get-NetIPsecRule unavailable.") | Out-Null
17880}
17881
17882# Active main-mode SAs
17883$result.AppendLine("") | Out-Null
17884$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
17885try {
17886    $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
17887    if ($mmSAs) {
17888        foreach ($sa in $mmSAs) {
17889            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
17890            $result.AppendLine("    AuthMethod: $($sa.LocalFirstId)  Cipher: $($sa.Cipher)") | Out-Null
17891        }
17892    } else {
17893        $result.AppendLine("  No active main-mode IPSec SAs.") | Out-Null
17894    }
17895} catch {
17896    $result.AppendLine("  Get-NetIPsecMainModeSA unavailable.") | Out-Null
17897}
17898
17899# Active quick-mode SAs
17900$result.AppendLine("") | Out-Null
17901$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
17902try {
17903    $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
17904    if ($qmSAs) {
17905        foreach ($sa in $qmSAs) {
17906            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
17907            $result.AppendLine("    Encapsulation: $($sa.EncapsulationMode)  Protocol: $($sa.TransportLayerProtocol)") | Out-Null
17908        }
17909    } else {
17910        $result.AppendLine("  No active quick-mode IPSec SAs.") | Out-Null
17911    }
17912} catch {
17913    $result.AppendLine("  Get-NetIPsecQuickModeSA unavailable.") | Out-Null
17914}
17915
17916# IKE service state
17917$result.AppendLine("") | Out-Null
17918$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
17919$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
17920if ($ikeAgentSvc) {
17921    $result.AppendLine("  PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
17922} else {
17923    $result.AppendLine("  PolicyAgent service not found.") | Out-Null
17924}
17925
17926# Findings
17927$findings = [System.Collections.Generic.List[string]]::new()
17928$mmSACount = 0
17929try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
17930if ($mmSACount -gt 0) {
17931    $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
17932}
17933
17934$result.AppendLine("") | Out-Null
17935$result.AppendLine("=== Findings ===") | Out-Null
17936if ($findings.Count -eq 0) {
17937    $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
17938} else {
17939    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17940}
17941
17942Write-Output $result.ToString()
17943"#;
17944    let out = run_powershell(script)?;
17945    Ok(format!("Host inspection: ipsec\n\n{out}"))
17946}
17947
17948#[cfg(not(windows))]
17949fn inspect_ipsec() -> Result<String, String> {
17950    let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
17951    if let Ok(o) = std::process::Command::new("ip")
17952        .args(["xfrm", "state"])
17953        .output()
17954    {
17955        let body = String::from_utf8_lossy(&o.stdout);
17956        if body.trim().is_empty() {
17957            out.push_str("  No active IPSec SAs.\n");
17958        } else {
17959            out.push_str(&body);
17960        }
17961    }
17962    out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
17963    if let Ok(o) = std::process::Command::new("ip")
17964        .args(["xfrm", "policy"])
17965        .output()
17966    {
17967        let body = String::from_utf8_lossy(&o.stdout);
17968        if body.trim().is_empty() {
17969            out.push_str("  No IPSec policies.\n");
17970        } else {
17971            out.push_str(&body);
17972        }
17973    }
17974    Ok(out)
17975}
17976
17977// ── NetBIOS ──────────────────────────────────────────────────────────────────
17978
17979#[cfg(windows)]
17980fn inspect_netbios() -> Result<String, String> {
17981    let script = r#"
17982$result = [System.Text.StringBuilder]::new()
17983
17984# NetBIOS node type and WINS per adapter
17985$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
17986try {
17987    $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
17988        Where-Object { $_.IPEnabled -eq $true }
17989    foreach ($a in $adapters) {
17990        $nodeType = switch ($a.TcpipNetbiosOptions) {
17991            0 { "EnableNetBIOSViaDHCP" }
17992            1 { "Enabled" }
17993            2 { "Disabled" }
17994            default { "Unknown ($($a.TcpipNetbiosOptions))" }
17995        }
17996        $result.AppendLine("  [$($a.Description)]") | Out-Null
17997        $result.AppendLine("    NetBIOS over TCP/IP: $nodeType") | Out-Null
17998        if ($a.WINSPrimaryServer) {
17999            $result.AppendLine("    WINS Primary:        $($a.WINSPrimaryServer)") | Out-Null
18000        }
18001        if ($a.WINSSecondaryServer) {
18002            $result.AppendLine("    WINS Secondary:      $($a.WINSSecondaryServer)") | Out-Null
18003        }
18004    }
18005} catch {
18006    $result.AppendLine("  Could not query NetBIOS adapter config.") | Out-Null
18007}
18008
18009# nbtstat -n — registered local NetBIOS names
18010$result.AppendLine("") | Out-Null
18011$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
18012try {
18013    $nbt = nbtstat -n 2>$null
18014    foreach ($line in $nbt) {
18015        $l = $line.Trim()
18016        if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
18017            $result.AppendLine("  $l") | Out-Null
18018        }
18019    }
18020} catch {
18021    $result.AppendLine("  nbtstat not available.") | Out-Null
18022}
18023
18024# NetBIOS session table
18025$result.AppendLine("") | Out-Null
18026$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
18027try {
18028    $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
18029    if ($sessions) {
18030        foreach ($s in $sessions) { $result.AppendLine("  $($s.Trim())") | Out-Null }
18031    } else {
18032        $result.AppendLine("  No active NetBIOS sessions.") | Out-Null
18033    }
18034} catch {
18035    $result.AppendLine("  Could not query NetBIOS sessions.") | Out-Null
18036}
18037
18038# Findings
18039$findings = [System.Collections.Generic.List[string]]::new()
18040try {
18041    $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
18042        Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
18043    if ($enabled) {
18044        $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
18045    }
18046    $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
18047        Where-Object { $_.WINSPrimaryServer }
18048    if ($wins) {
18049        $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
18050    }
18051} catch {}
18052
18053$result.AppendLine("") | Out-Null
18054$result.AppendLine("=== Findings ===") | Out-Null
18055if ($findings.Count -eq 0) {
18056    $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
18057} else {
18058    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
18059}
18060
18061Write-Output $result.ToString()
18062"#;
18063    let out = run_powershell(script)?;
18064    Ok(format!("Host inspection: netbios\n\n{out}"))
18065}
18066
18067#[cfg(not(windows))]
18068fn inspect_netbios() -> Result<String, String> {
18069    let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
18070    if let Ok(o) = std::process::Command::new("nmblookup")
18071        .arg("-A")
18072        .arg("localhost")
18073        .output()
18074    {
18075        out.push_str(&String::from_utf8_lossy(&o.stdout));
18076    } else {
18077        out.push_str("  nmblookup not available (Samba not installed).\n");
18078    }
18079    Ok(out)
18080}
18081
18082// ── NIC Teaming ──────────────────────────────────────────────────────────────
18083
18084#[cfg(windows)]
18085fn inspect_nic_teaming() -> Result<String, String> {
18086    let script = r#"
18087$result = [System.Text.StringBuilder]::new()
18088
18089# Team inventory
18090$result.AppendLine("=== NIC teams ===") | Out-Null
18091try {
18092    $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
18093    if ($teams) {
18094        foreach ($t in $teams) {
18095            $result.AppendLine("  Team: $($t.Name)") | Out-Null
18096            $result.AppendLine("    Mode:            $($t.TeamingMode)") | Out-Null
18097            $result.AppendLine("    LB Algorithm:    $($t.LoadBalancingAlgorithm)") | Out-Null
18098            $result.AppendLine("    Status:          $($t.Status)") | Out-Null
18099            $result.AppendLine("    Members:         $($t.Members -join ', ')") | Out-Null
18100            $result.AppendLine("    VLANs:           $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
18101        }
18102    } else {
18103        $result.AppendLine("  No NIC teams configured on this machine.") | Out-Null
18104    }
18105} catch {
18106    $result.AppendLine("  Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
18107}
18108
18109# Team members detail
18110$result.AppendLine("") | Out-Null
18111$result.AppendLine("=== Team member detail ===") | Out-Null
18112try {
18113    $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
18114    if ($members) {
18115        foreach ($m in $members) {
18116            $result.AppendLine("  [$($m.Team)] $($m.Name)  Role=$($m.AdministrativeMode)  Status=$($m.OperationalStatus)") | Out-Null
18117        }
18118    } else {
18119        $result.AppendLine("  No team members found.") | Out-Null
18120    }
18121} catch {
18122    $result.AppendLine("  Could not query team members.") | Out-Null
18123}
18124
18125# Findings
18126$findings = [System.Collections.Generic.List[string]]::new()
18127try {
18128    $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
18129    if ($degraded) {
18130        foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
18131    }
18132    $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
18133    if ($downMembers) {
18134        foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
18135    }
18136} catch {}
18137
18138$result.AppendLine("") | Out-Null
18139$result.AppendLine("=== Findings ===") | Out-Null
18140if ($findings.Count -eq 0) {
18141    $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
18142} else {
18143    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
18144}
18145
18146Write-Output $result.ToString()
18147"#;
18148    let out = run_powershell(script)?;
18149    Ok(format!("Host inspection: nic_teaming\n\n{out}"))
18150}
18151
18152#[cfg(not(windows))]
18153fn inspect_nic_teaming() -> Result<String, String> {
18154    let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
18155    if let Ok(o) = std::process::Command::new("cat")
18156        .arg("/proc/net/bonding/bond0")
18157        .output()
18158    {
18159        if o.status.success() {
18160            out.push_str(&String::from_utf8_lossy(&o.stdout));
18161        } else {
18162            out.push_str("  No bond0 interface found.\n");
18163        }
18164    }
18165    if let Ok(o) = std::process::Command::new("ip")
18166        .args(["link", "show", "type", "bond"])
18167        .output()
18168    {
18169        let body = String::from_utf8_lossy(&o.stdout);
18170        if !body.trim().is_empty() {
18171            out.push_str("\n=== Bond links (ip link) ===\n");
18172            out.push_str(&body);
18173        }
18174    }
18175    Ok(out)
18176}
18177
18178// ── SNMP ─────────────────────────────────────────────────────────────────────
18179
18180#[cfg(windows)]
18181fn inspect_snmp() -> Result<String, String> {
18182    let script = r#"
18183$result = [System.Text.StringBuilder]::new()
18184
18185# SNMP service state
18186$result.AppendLine("=== SNMP service state ===") | Out-Null
18187$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
18188if ($svc) {
18189    $result.AppendLine("  SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
18190} else {
18191    $result.AppendLine("  SNMP Agent service not installed.") | Out-Null
18192}
18193
18194$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
18195if ($svcTrap) {
18196    $result.AppendLine("  SNMP Trap service:  $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
18197}
18198
18199# Community strings (presence only — values redacted)
18200$result.AppendLine("") | Out-Null
18201$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
18202try {
18203    $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
18204    if ($communities) {
18205        $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
18206        if ($names) {
18207            foreach ($n in $names) {
18208                $result.AppendLine("  Community: '$n'  (value redacted)") | Out-Null
18209            }
18210        } else {
18211            $result.AppendLine("  No community strings configured.") | Out-Null
18212        }
18213    } else {
18214        $result.AppendLine("  Registry key not found (SNMP may not be configured).") | Out-Null
18215    }
18216} catch {
18217    $result.AppendLine("  Could not read community strings (SNMP not configured or access denied).") | Out-Null
18218}
18219
18220# Permitted managers
18221$result.AppendLine("") | Out-Null
18222$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
18223try {
18224    $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
18225    if ($managers) {
18226        $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
18227        if ($mgrs) {
18228            foreach ($m in $mgrs) { $result.AppendLine("  $m") | Out-Null }
18229        } else {
18230            $result.AppendLine("  No permitted managers configured (accepts from any host).") | Out-Null
18231        }
18232    } else {
18233        $result.AppendLine("  No manager restrictions configured.") | Out-Null
18234    }
18235} catch {
18236    $result.AppendLine("  Could not read permitted managers.") | Out-Null
18237}
18238
18239# Findings
18240$findings = [System.Collections.Generic.List[string]]::new()
18241$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
18242if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
18243    $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
18244    try {
18245        $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
18246        $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
18247        if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
18248    } catch {}
18249}
18250
18251$result.AppendLine("") | Out-Null
18252$result.AppendLine("=== Findings ===") | Out-Null
18253if ($findings.Count -eq 0) {
18254    $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
18255} else {
18256    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
18257}
18258
18259Write-Output $result.ToString()
18260"#;
18261    let out = run_powershell(script)?;
18262    Ok(format!("Host inspection: snmp\n\n{out}"))
18263}
18264
18265#[cfg(not(windows))]
18266fn inspect_snmp() -> Result<String, String> {
18267    let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
18268    for svc in &["snmpd", "snmp"] {
18269        if let Ok(o) = std::process::Command::new("systemctl")
18270            .args(["is-active", svc])
18271            .output()
18272        {
18273            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
18274            let _ = write!(out, "  {svc}: {status}\n");
18275        }
18276    }
18277    out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
18278    if let Ok(o) = std::process::Command::new("grep")
18279        .args(["-i", "community", "/etc/snmp/snmpd.conf"])
18280        .output()
18281    {
18282        if o.status.success() {
18283            for line in String::from_utf8_lossy(&o.stdout).lines() {
18284                let _ = write!(out, "  {line}\n");
18285            }
18286        } else {
18287            out.push_str("  /etc/snmp/snmpd.conf not found or no community lines.\n");
18288        }
18289    }
18290    Ok(out)
18291}
18292
18293// ── Port Test ─────────────────────────────────────────────────────────────────
18294
18295#[cfg(windows)]
18296fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
18297    let target_host = host.unwrap_or("8.8.8.8");
18298    let target_port = port.unwrap_or(443);
18299    let escaped_host = ps_escape_single_quoted(target_host);
18300
18301    let script = format!(
18302        r#"
18303$result = [System.Text.StringBuilder]::new()
18304$result.AppendLine("=== Port reachability test ===") | Out-Null
18305$result.AppendLine("  Target: {target_host}:{target_port}") | Out-Null
18306$result.AppendLine("") | Out-Null
18307
18308try {{
18309    $test = Test-NetConnection -ComputerName '{escaped_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
18310    if ($test) {{
18311        $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
18312        $result.AppendLine("  Result:          $status") | Out-Null
18313        $result.AppendLine("  Remote address:  $($test.RemoteAddress)") | Out-Null
18314        $result.AppendLine("  Remote port:     $($test.RemotePort)") | Out-Null
18315        if ($test.PingSucceeded) {{
18316            $result.AppendLine("  ICMP ping:       Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
18317        }} else {{
18318            $result.AppendLine("  ICMP ping:       Failed (host may block ICMP)") | Out-Null
18319        }}
18320        $result.AppendLine("  Interface used:  $($test.InterfaceAlias)") | Out-Null
18321        $result.AppendLine("  Source address:  $($test.SourceAddress.IPAddress)") | Out-Null
18322
18323        $result.AppendLine("") | Out-Null
18324        $result.AppendLine("=== Findings ===") | Out-Null
18325        if ($test.TcpTestSucceeded) {{
18326            $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
18327        }} else {{
18328            $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
18329            $result.AppendLine("  Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
18330        }}
18331    }}
18332}} catch {{
18333    $result.AppendLine("  Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
18334}}
18335
18336Write-Output $result.ToString()
18337"#
18338    );
18339    let out = run_powershell(&script)?;
18340    Ok(format!("Host inspection: port_test\n\n{out}"))
18341}
18342
18343#[cfg(not(windows))]
18344fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
18345    let target_host = host.unwrap_or("8.8.8.8");
18346    let target_port = port.unwrap_or(443);
18347    let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n  Target: {target_host}:{target_port}\n\n");
18348    // nc -zv with timeout
18349    let nc = std::process::Command::new("nc")
18350        .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
18351        .output();
18352    match nc {
18353        Ok(o) => {
18354            let stderr = String::from_utf8_lossy(&o.stderr);
18355            let stdout = String::from_utf8_lossy(&o.stdout);
18356            let body = if !stdout.trim().is_empty() {
18357                stdout.as_ref()
18358            } else {
18359                stderr.as_ref()
18360            };
18361            let _ = write!(out, "  {}\n", body.trim());
18362            out.push_str("\n=== Findings ===\n");
18363            if o.status.success() {
18364                let _ = write!(out, "- Port {target_port} on {target_host} is OPEN.\n");
18365            } else {
18366                let _ = write!(
18367                    out,
18368                    "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
18369                );
18370            }
18371        }
18372        Err(e) => {
18373            let _ = write!(out, "  nc not available: {e}\n");
18374        }
18375    }
18376    Ok(out)
18377}
18378
18379// ── Network Profile ───────────────────────────────────────────────────────────
18380
18381#[cfg(windows)]
18382fn inspect_network_profile() -> Result<String, String> {
18383    let script = r#"
18384$result = [System.Text.StringBuilder]::new()
18385
18386$result.AppendLine("=== Network location profiles ===") | Out-Null
18387try {
18388    $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
18389    if ($profiles) {
18390        foreach ($p in $profiles) {
18391            $result.AppendLine("  Interface: $($p.InterfaceAlias)") | Out-Null
18392            $result.AppendLine("    Network name:    $($p.Name)") | Out-Null
18393            $result.AppendLine("    Category:        $($p.NetworkCategory)") | Out-Null
18394            $result.AppendLine("    IPv4 conn:       $($p.IPv4Connectivity)") | Out-Null
18395            $result.AppendLine("    IPv6 conn:       $($p.IPv6Connectivity)") | Out-Null
18396            $result.AppendLine("") | Out-Null
18397        }
18398    } else {
18399        $result.AppendLine("  No network connection profiles found.") | Out-Null
18400    }
18401} catch {
18402    $result.AppendLine("  Could not query network profiles.") | Out-Null
18403}
18404
18405# Findings
18406$findings = [System.Collections.Generic.List[string]]::new()
18407try {
18408    $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
18409    if ($pub) {
18410        foreach ($p in $pub) {
18411            $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
18412        }
18413    }
18414    $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
18415    if ($domain) {
18416        foreach ($d in $domain) {
18417            $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
18418        }
18419    }
18420} catch {}
18421
18422$result.AppendLine("=== Findings ===") | Out-Null
18423if ($findings.Count -eq 0) {
18424    $result.AppendLine("- Network profiles look normal.") | Out-Null
18425} else {
18426    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
18427}
18428
18429Write-Output $result.ToString()
18430"#;
18431    let out = run_powershell(script)?;
18432    Ok(format!("Host inspection: network_profile\n\n{out}"))
18433}
18434
18435#[cfg(not(windows))]
18436fn inspect_network_profile() -> Result<String, String> {
18437    let mut out = String::from(
18438        "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
18439    );
18440    if let Ok(o) = std::process::Command::new("nmcli")
18441        .args([
18442            "-t",
18443            "-f",
18444            "NAME,TYPE,STATE,DEVICE",
18445            "connection",
18446            "show",
18447            "--active",
18448        ])
18449        .output()
18450    {
18451        out.push_str(&String::from_utf8_lossy(&o.stdout));
18452    } else {
18453        out.push_str("  nmcli not available.\n");
18454    }
18455    Ok(out)
18456}
18457
18458// ── Storage Spaces ────────────────────────────────────────────────────────────
18459
18460#[cfg(windows)]
18461fn inspect_storage_spaces() -> Result<String, String> {
18462    let script = r#"
18463$result = [System.Text.StringBuilder]::new()
18464
18465# Storage Pools
18466try {
18467    $pools = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue
18468    if ($pools) {
18469        $result.AppendLine("=== Storage Pools ===") | Out-Null
18470        foreach ($pool in $pools) {
18471            $health = $pool.HealthStatus
18472            $oper   = $pool.OperationalStatus
18473            $sizGB  = [math]::Round($pool.Size / 1GB, 1)
18474            $allocGB= [math]::Round($pool.AllocatedSize / 1GB, 1)
18475            $result.AppendLine("  Pool: $($pool.FriendlyName)  Size: ${sizGB}GB  Allocated: ${allocGB}GB  Health: $health  Status: $oper") | Out-Null
18476        }
18477        $result.AppendLine("") | Out-Null
18478    } else {
18479        $result.AppendLine("=== Storage Pools ===") | Out-Null
18480        $result.AppendLine("  No Storage Spaces pools configured.") | Out-Null
18481        $result.AppendLine("") | Out-Null
18482    }
18483} catch {
18484    $result.AppendLine("=== Storage Pools ===") | Out-Null
18485    $result.AppendLine("  Unable to query storage pools (may require elevation).") | Out-Null
18486    $result.AppendLine("") | Out-Null
18487}
18488
18489# Virtual Disks
18490try {
18491    $vdisks = Get-VirtualDisk -ErrorAction SilentlyContinue
18492    if ($vdisks) {
18493        $result.AppendLine("=== Virtual Disks ===") | Out-Null
18494        foreach ($vd in $vdisks) {
18495            $health  = $vd.HealthStatus
18496            $oper    = $vd.OperationalStatus
18497            $layout  = $vd.ResiliencySettingName
18498            $sizGB   = [math]::Round($vd.Size / 1GB, 1)
18499            $result.AppendLine("  VDisk: $($vd.FriendlyName)  Layout: $layout  Size: ${sizGB}GB  Health: $health  Status: $oper") | Out-Null
18500        }
18501        $result.AppendLine("") | Out-Null
18502    } else {
18503        $result.AppendLine("=== Virtual Disks ===") | Out-Null
18504        $result.AppendLine("  No Storage Spaces virtual disks configured.") | Out-Null
18505        $result.AppendLine("") | Out-Null
18506    }
18507} catch {
18508    $result.AppendLine("=== Virtual Disks ===") | Out-Null
18509    $result.AppendLine("  Unable to query virtual disks.") | Out-Null
18510    $result.AppendLine("") | Out-Null
18511}
18512
18513# Physical Disks in pools
18514try {
18515    $pdisks = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.Usage -ne 'Journal' }
18516    if ($pdisks) {
18517        $result.AppendLine("=== Physical Disks ===") | Out-Null
18518        foreach ($pd in $pdisks) {
18519            $sizGB  = [math]::Round($pd.Size / 1GB, 1)
18520            $health = $pd.HealthStatus
18521            $usage  = $pd.Usage
18522            $media  = $pd.MediaType
18523            $result.AppendLine("  $($pd.FriendlyName)  ${sizGB}GB  $media  Usage: $usage  Health: $health") | Out-Null
18524        }
18525        $result.AppendLine("") | Out-Null
18526    }
18527} catch {}
18528
18529# Findings
18530$findings = @()
18531try {
18532    $unhealthy = Get-StoragePool -IsPrimordial $false -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
18533    foreach ($p in $unhealthy) { $findings += "WARN: Pool '$($p.FriendlyName)' health is $($p.HealthStatus)" }
18534    $degraded = Get-VirtualDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -ne 'Healthy' }
18535    foreach ($v in $degraded) { $findings += "WARN: VDisk '$($v.FriendlyName)' health is $($v.HealthStatus) — check for failed drives" }
18536    $failedPd = Get-PhysicalDisk -ErrorAction SilentlyContinue | Where-Object { $_.HealthStatus -eq 'Unhealthy' -or $_.OperationalStatus -eq 'Lost Communication' }
18537    foreach ($d in $failedPd) { $findings += "CRITICAL: Physical disk '$($d.FriendlyName)' is $($d.HealthStatus) / $($d.OperationalStatus)" }
18538} catch {}
18539
18540if ($findings.Count -gt 0) {
18541    $result.AppendLine("=== Findings ===") | Out-Null
18542    foreach ($f in $findings) { $result.AppendLine("  $f") | Out-Null }
18543} else {
18544    $result.AppendLine("=== Findings ===") | Out-Null
18545    $result.AppendLine("  All storage pool components healthy (or no Storage Spaces configured).") | Out-Null
18546}
18547
18548Write-Output $result.ToString().TrimEnd()
18549"#;
18550    let out = run_powershell(script)?;
18551    Ok(format!("Host inspection: storage_spaces\n\n{out}"))
18552}
18553
18554#[cfg(not(windows))]
18555fn inspect_storage_spaces() -> Result<String, String> {
18556    let mut out = String::from("Host inspection: storage_spaces\n\n");
18557    // Linux: check mdadm software RAID
18558    let mdstat = std::fs::read_to_string("/proc/mdstat").unwrap_or_default();
18559    if !mdstat.is_empty() {
18560        out.push_str("=== Software RAID (/proc/mdstat) ===\n");
18561        out.push_str(&mdstat);
18562    } else {
18563        out.push_str("No mdadm software RAID detected (/proc/mdstat not found or empty).\n");
18564    }
18565    // Check LVM
18566    if let Ok(o) = Command::new("lvs")
18567        .args(["--noheadings", "-o", "lv_name,vg_name,lv_size,lv_attr"])
18568        .output()
18569    {
18570        let lvs = String::from_utf8_lossy(&o.stdout).into_owned();
18571        if !lvs.trim().is_empty() {
18572            out.push_str("\n=== LVM Logical Volumes ===\n");
18573            out.push_str(&lvs);
18574        }
18575    }
18576    Ok(out)
18577}
18578
18579// ── Defender Quarantine / Threat History ─────────────────────────────────────
18580
18581#[cfg(windows)]
18582fn inspect_defender_quarantine(max_entries: usize) -> Result<String, String> {
18583    let limit = max_entries.min(50);
18584    let script = format!(
18585        r#"
18586$result = [System.Text.StringBuilder]::new()
18587
18588# Current threat detections (active + quarantined)
18589try {{
18590    $threats = Get-MpThreatDetection -ErrorAction SilentlyContinue | Sort-Object InitialDetectionTime -Descending | Select-Object -First {limit}
18591    if ($threats) {{
18592        $result.AppendLine("=== Recent Threat Detections (last {limit}) ===") | Out-Null
18593        foreach ($t in $threats) {{
18594            $name    = (Get-MpThreat -ThreatID $t.ThreatID -ErrorAction SilentlyContinue).ThreatName
18595            if (-not $name) {{ $name = "ID:$($t.ThreatID)" }}
18596            $time    = $t.InitialDetectionTime.ToString("yyyy-MM-dd HH:mm")
18597            $action  = $t.ActionSuccess
18598            $status  = $t.CurrentThreatExecutionStatusID
18599            $result.AppendLine("  [$time] $name  ActionSuccess:$action  Status:$status") | Out-Null
18600        }}
18601        $result.AppendLine("") | Out-Null
18602    }} else {{
18603        $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18604        $result.AppendLine("  No threat detections on record — Defender history is clean.") | Out-Null
18605        $result.AppendLine("") | Out-Null
18606    }}
18607}} catch {{
18608    $result.AppendLine("=== Recent Threat Detections ===") | Out-Null
18609    $result.AppendLine("  Unable to query threat detections: $_") | Out-Null
18610    $result.AppendLine("") | Out-Null
18611}}
18612
18613# Quarantine items
18614try {{
18615    $quarantine = Get-MpThreat -ErrorAction SilentlyContinue | Where-Object {{ $_.IsActive -eq $false }} | Select-Object -First {limit}
18616    if ($quarantine) {{
18617        $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18618        foreach ($q in $quarantine) {{
18619            $result.AppendLine("  $($q.ThreatName)  Severity:$($q.SeverityID)  Category:$($q.CategoryID)  Active:$($q.IsActive)") | Out-Null
18620        }}
18621        $result.AppendLine("") | Out-Null
18622    }} else {{
18623        $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18624        $result.AppendLine("  No quarantined threats found.") | Out-Null
18625        $result.AppendLine("") | Out-Null
18626    }}
18627}} catch {{
18628    $result.AppendLine("=== Quarantined / Remediated Threats ===") | Out-Null
18629    $result.AppendLine("  Unable to query quarantine list: $_") | Out-Null
18630    $result.AppendLine("") | Out-Null
18631}}
18632
18633# Defender scan stats
18634try {{
18635    $status = Get-MpComputerStatus -ErrorAction SilentlyContinue
18636    if ($status) {{
18637        $lastScan   = $status.QuickScanStartTime
18638        $lastFull   = $status.FullScanStartTime
18639        $sigDate    = $status.AntivirusSignatureLastUpdated
18640        $result.AppendLine("=== Defender Scan Summary ===") | Out-Null
18641        $result.AppendLine("  Last quick scan : $lastScan") | Out-Null
18642        $result.AppendLine("  Last full scan  : $lastFull") | Out-Null
18643        $result.AppendLine("  Signature date  : $sigDate") | Out-Null
18644    }}
18645}} catch {{}}
18646
18647Write-Output $result.ToString().TrimEnd()
18648"#,
18649        limit = limit
18650    );
18651    let out = run_powershell(&script)?;
18652    Ok(format!("Host inspection: defender_quarantine\n\n{out}"))
18653}
18654
18655// ── inspect_domain_health ─────────────────────────────────────────────────────
18656
18657#[cfg(windows)]
18658fn inspect_domain_health() -> Result<String, String> {
18659    let script = r#"
18660$result = [System.Text.StringBuilder]::new()
18661
18662# Domain membership
18663try {
18664    $cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
18665    $joined = $cs.PartOfDomain
18666    $domain = $cs.Domain
18667    $result.AppendLine("=== Domain Membership ===") | Out-Null
18668    $result.AppendLine("  Join Status : $(if ($joined) { 'DOMAIN JOINED' } else { 'WORKGROUP' })") | Out-Null
18669    if ($joined) { $result.AppendLine("  Domain      : $domain") | Out-Null }
18670    $result.AppendLine("  Computer    : $($cs.Name)") | Out-Null
18671} catch {
18672    $result.AppendLine("  Domain membership check failed: $_") | Out-Null
18673}
18674
18675# dsregcmd device registration state
18676try {
18677    $dsreg = dsregcmd /status 2>&1 | Where-Object { $_ -match '(AzureAdJoined|DomainJoined|WorkplaceJoined|TenantName|DeviceId|MdmUrl)' }
18678    if ($dsreg) {
18679        $result.AppendLine("") | Out-Null
18680        $result.AppendLine("=== Device Registration (dsregcmd) ===") | Out-Null
18681        foreach ($line in $dsreg) { $result.AppendLine("  $($line.Trim())") | Out-Null }
18682    }
18683} catch {}
18684
18685# DC discovery via nltest
18686$result.AppendLine("") | Out-Null
18687$result.AppendLine("=== Domain Controller Discovery ===") | Out-Null
18688try {
18689    $nl = nltest /dsgetdc:. 2>&1
18690    $dc_name = $null
18691    foreach ($line in $nl) {
18692        if ($line -match '(DC:|Address:|Dom Guid|DomainName)') {
18693            $result.AppendLine("  $($line.Trim())") | Out-Null
18694        }
18695        if ($line -match 'DC: \\\\(.+)') { $dc_name = $Matches[1].Trim() }
18696    }
18697    if ($dc_name) {
18698        $result.AppendLine("") | Out-Null
18699        $result.AppendLine("=== DC Port Connectivity (DC: $dc_name) ===") | Out-Null
18700        foreach ($entry in @(@{p=389;n='LDAP'},@{p=636;n='LDAPS'},@{p=88;n='Kerberos'},@{p=3268;n='GC-LDAP'})) {
18701            try {
18702                $tcp = New-Object System.Net.Sockets.TcpClient
18703                $conn = $tcp.BeginConnect($dc_name, $entry.p, $null, $null)
18704                $ok = $conn.AsyncWaitHandle.WaitOne(1200, $false)
18705                $tcp.Close()
18706                $status = if ($ok) { 'OPEN' } else { 'TIMEOUT' }
18707            } catch { $status = 'FAILED' }
18708            $result.AppendLine("  Port $($entry.p) ($($entry.n)): $status") | Out-Null
18709        }
18710    }
18711} catch {
18712    $result.AppendLine("  nltest unavailable (not domain-joined or missing RSAT): $_") | Out-Null
18713}
18714
18715# Last GPO machine refresh time
18716try {
18717    $gpoKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Group Policy\State\Machine\Extension-List\{00000000-0000-0000-0000-000000000000}'
18718    if (Test-Path $gpoKey) {
18719        $gpo = Get-ItemProperty $gpoKey -ErrorAction SilentlyContinue
18720        $result.AppendLine("") | Out-Null
18721        $result.AppendLine("=== Group Policy Last Refresh ===") | Out-Null
18722        $result.AppendLine("  Machine GPO last applied: $($gpo.EndTime)") | Out-Null
18723    }
18724} catch {}
18725
18726Write-Output $result.ToString().TrimEnd()
18727"#;
18728    let out = run_powershell(script)?;
18729    Ok(format!("Host inspection: domain_health\n\n{out}"))
18730}
18731
18732#[cfg(not(windows))]
18733fn inspect_domain_health() -> Result<String, String> {
18734    let mut out = String::from("Host inspection: domain_health\n\n");
18735    for cmd_args in &[vec!["realm", "list"], vec!["sssd", "--version"]] {
18736        if let Ok(o) = Command::new(cmd_args[0]).args(&cmd_args[1..]).output() {
18737            let s = String::from_utf8_lossy(&o.stdout);
18738            if !s.trim().is_empty() {
18739                let _ = write!(out, "$ {}\n{}\n", cmd_args.join(" "), s.trim_end());
18740            }
18741        }
18742    }
18743    if out.trim_end().ends_with("domain_health") {
18744        out.push_str("Not domain-joined or realm/sssd not installed.\n");
18745    }
18746    Ok(out)
18747}
18748
18749// ── inspect_service_dependencies ─────────────────────────────────────────────
18750
18751#[cfg(windows)]
18752fn inspect_service_dependencies(max_entries: usize) -> Result<String, String> {
18753    let limit = max_entries.min(60);
18754    let script = format!(
18755        r#"
18756$result = [System.Text.StringBuilder]::new()
18757$result.AppendLine("=== Service Dependency Graph ===") | Out-Null
18758$result.AppendLine("Format: [Status] ServiceName — requires: ... | needed by: ...") | Out-Null
18759$result.AppendLine("") | Out-Null
18760$svc = Get-Service | Where-Object {{ $_.DependentServices.Count -gt 0 -or $_.RequiredServices.Count -gt 0 }} | Sort-Object Name | Select-Object -First {limit}
18761foreach ($s in $svc) {{
18762    $req  = if ($s.RequiredServices.Count  -gt 0) {{ "requires: $($s.RequiredServices.Name  -join ', ')" }} else {{ "" }}
18763    $dep  = if ($s.DependentServices.Count -gt 0) {{ "needed by: $($s.DependentServices.Name -join ', ')" }} else {{ "" }}
18764    $parts = @($req, $dep) | Where-Object {{ $_ }}
18765    if ($parts) {{
18766        $result.AppendLine("  [$($s.Status)] $($s.Name) — $($parts -join ' | ')") | Out-Null
18767    }}
18768}}
18769Write-Output $result.ToString().TrimEnd()
18770"#,
18771        limit = limit
18772    );
18773    let out = run_powershell(&script)?;
18774    Ok(format!("Host inspection: service_dependencies\n\n{out}"))
18775}
18776
18777#[cfg(not(windows))]
18778fn inspect_service_dependencies(_max_entries: usize) -> Result<String, String> {
18779    let out = Command::new("systemctl")
18780        .args(["list-dependencies", "--no-pager", "--plain"])
18781        .output()
18782        .ok()
18783        .and_then(|o| String::from_utf8(o.stdout).ok())
18784        .unwrap_or_else(|| "systemctl not available.\n".to_string());
18785    Ok(format!(
18786        "Host inspection: service_dependencies\n\n{}",
18787        out.trim_end()
18788    ))
18789}
18790
18791// ── inspect_wmi_health ────────────────────────────────────────────────────────
18792
18793#[cfg(windows)]
18794fn inspect_wmi_health() -> Result<String, String> {
18795    let script = r#"
18796$result = [System.Text.StringBuilder]::new()
18797$result.AppendLine("=== WMI Repository Health ===") | Out-Null
18798
18799# Basic WMI query test
18800try {
18801    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
18802    $result.AppendLine("  Query (Win32_OperatingSystem): OK") | Out-Null
18803    $result.AppendLine("  OS: $($os.Caption) Build $($os.BuildNumber)") | Out-Null
18804} catch {
18805    $result.AppendLine("  Query FAILED: $_") | Out-Null
18806    $result.AppendLine("  FINDING: WMI may be corrupt. Run winmgmt /verifyrepository") | Out-Null
18807}
18808
18809# Repository integrity
18810try {
18811    $verify = & winmgmt /verifyrepository 2>&1
18812    $result.AppendLine("  winmgmt /verifyrepository: $verify") | Out-Null
18813} catch {
18814    $result.AppendLine("  winmgmt check unavailable: $_") | Out-Null
18815}
18816
18817# WMI service state
18818$svc = Get-Service winmgmt -ErrorAction SilentlyContinue
18819if ($svc) {
18820    $result.AppendLine("  Service (winmgmt): $($svc.Status) / $($svc.StartType)") | Out-Null
18821}
18822
18823# Repository folder size
18824$repPath = "$env:SystemRoot\System32\wbem\Repository"
18825if (Test-Path $repPath) {
18826    $bytes = (Get-ChildItem $repPath -Recurse -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
18827    $mb = [math]::Round($bytes / 1MB, 1)
18828    $result.AppendLine("  Repository size: $mb MB  ($repPath)") | Out-Null
18829    if ($mb -gt 200) {
18830        $result.AppendLine("  FINDING: Repository is unusually large (>200 MB). May indicate corruption or bloat.") | Out-Null
18831    }
18832}
18833
18834$result.AppendLine("") | Out-Null
18835$result.AppendLine("=== Recovery Steps (if corrupt) ===") | Out-Null
18836$result.AppendLine("  1. net stop winmgmt") | Out-Null
18837$result.AppendLine("  2. winmgmt /salvagerepository   (try first)") | Out-Null
18838$result.AppendLine("  3. winmgmt /resetrepository     (last resort — loses custom namespaces)") | Out-Null
18839$result.AppendLine("  4. net start winmgmt") | Out-Null
18840
18841Write-Output $result.ToString().TrimEnd()
18842"#;
18843    let out = run_powershell(script)?;
18844    Ok(format!("Host inspection: wmi_health\n\n{out}"))
18845}
18846
18847#[cfg(not(windows))]
18848fn inspect_wmi_health() -> Result<String, String> {
18849    Ok("Host inspection: wmi_health\n\nWMI is Windows-only. On Linux use 'systemctl status' for service health.\n".to_string())
18850}
18851
18852// ── inspect_local_security_policy ────────────────────────────────────────────
18853
18854#[cfg(windows)]
18855fn inspect_local_security_policy() -> Result<String, String> {
18856    let script = r#"
18857$result = [System.Text.StringBuilder]::new()
18858$result.AppendLine("=== Local Account & Password Policy ===") | Out-Null
18859$na = net accounts 2>&1
18860foreach ($line in $na) {
18861    if ($line -match '(password|lockout|observation|force|maximum|minimum|length|duration)') {
18862        $result.AppendLine("  $($line.Trim())") | Out-Null
18863    }
18864}
18865
18866$result.AppendLine("") | Out-Null
18867$result.AppendLine("=== LAN Manager / NTLM Authentication Level ===") | Out-Null
18868try {
18869    $lmLevel = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name LmCompatibilityLevel -ErrorAction SilentlyContinue).LmCompatibilityLevel
18870    if ($null -eq $lmLevel) { $lmLevel = 3 }
18871    $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'}
18872    $result.AppendLine("  LmCompatibilityLevel: $lmLevel — $($map[$lmLevel])") | Out-Null
18873    if ($lmLevel -lt 3) {
18874        $result.AppendLine("  FINDING: LmCompatibilityLevel < 3 allows weak LM/NTLM. Recommend level 3 or higher.") | Out-Null
18875    }
18876} catch {}
18877
18878$result.AppendLine("") | Out-Null
18879$result.AppendLine("=== UAC Settings ===") | Out-Null
18880try {
18881    $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -ErrorAction SilentlyContinue
18882    if ($uac) {
18883        $result.AppendLine("  UAC Enabled             : $($uac.EnableLUA)   (1=on, 0=disabled)") | Out-Null
18884        $behavMap = @{0='No prompt (risk)'; 1='Prompt for creds'; 2='Prompt for consent'; 5='Secure consent (default)'}
18885        $bval = $uac.ConsentPromptBehaviorAdmin
18886        $result.AppendLine("  Admin Prompt Behavior   : $bval — $($behavMap[$bval])") | Out-Null
18887        if ($uac.EnableLUA -eq 0) {
18888            $result.AppendLine("  FINDING: UAC is disabled. Processes run with full admin rights without prompting.") | Out-Null
18889        }
18890    }
18891} catch {}
18892
18893Write-Output $result.ToString().TrimEnd()
18894"#;
18895    let out = run_powershell(script)?;
18896    Ok(format!("Host inspection: local_security_policy\n\n{out}"))
18897}
18898
18899#[cfg(not(windows))]
18900fn inspect_local_security_policy() -> Result<String, String> {
18901    let mut out = String::from("Host inspection: local_security_policy\n\n");
18902    if let Ok(content) = std::fs::read_to_string("/etc/login.defs") {
18903        out.push_str("=== /etc/login.defs ===\n");
18904        for line in content.lines() {
18905            let t = line.trim();
18906            if !t.is_empty() && !t.starts_with('#') {
18907                let _ = write!(out, "  {t}\n");
18908            }
18909        }
18910    }
18911    Ok(out)
18912}
18913
18914// ── inspect_usb_history ───────────────────────────────────────────────────────
18915
18916#[cfg(windows)]
18917fn inspect_usb_history(max_entries: usize) -> Result<String, String> {
18918    let limit = max_entries.min(50);
18919    let script = format!(
18920        r#"
18921$result = [System.Text.StringBuilder]::new()
18922$result.AppendLine("=== USB Device History (USBSTOR Registry) ===") | Out-Null
18923$usbPath = 'HKLM:\SYSTEM\CurrentControlSet\Enum\USBSTOR'
18924if (Test-Path $usbPath) {{
18925    $count = 0
18926    $seen = @{{}}
18927    $classes = Get-ChildItem $usbPath -ErrorAction SilentlyContinue
18928    foreach ($class in $classes) {{
18929        $instances = Get-ChildItem $class.PSPath -ErrorAction SilentlyContinue
18930        foreach ($inst in $instances) {{
18931            if ($count -ge {limit}) {{ break }}
18932            try {{
18933                $props = Get-ItemProperty $inst.PSPath -ErrorAction SilentlyContinue
18934                $fn = if ($props.FriendlyName) {{ $props.FriendlyName }} else {{ $class.PSChildName }}
18935                if (-not $seen[$fn]) {{
18936                    $seen[$fn] = $true
18937                    $result.AppendLine("  $fn") | Out-Null
18938                    $count++
18939                }}
18940            }} catch {{}}
18941        }}
18942    }}
18943    if ($count -eq 0) {{
18944        $result.AppendLine("  No USB storage devices found in registry.") | Out-Null
18945    }} else {{
18946        $result.AppendLine("") | Out-Null
18947        $result.AppendLine("  ($count unique devices; requires elevation for full history)") | Out-Null
18948    }}
18949}} else {{
18950    $result.AppendLine("  USBSTOR key not found. Requires elevation or USB storage policy may block it.") | Out-Null
18951}}
18952Write-Output $result.ToString().TrimEnd()
18953"#,
18954        limit = limit
18955    );
18956    let out = run_powershell(&script)?;
18957    Ok(format!("Host inspection: usb_history\n\n{out}"))
18958}
18959
18960#[cfg(not(windows))]
18961fn inspect_usb_history(_max_entries: usize) -> Result<String, String> {
18962    let mut out = String::from("Host inspection: usb_history\n\n");
18963    if let Ok(o) = Command::new("journalctl")
18964        .args(["-k", "--no-pager", "-q", "--since", "30 days ago"])
18965        .output()
18966    {
18967        let s = String::from_utf8_lossy(&o.stdout);
18968        let usb_lines: Vec<&str> = s
18969            .lines()
18970            .filter(|l| l.to_ascii_lowercase().contains("usb"))
18971            .take(30)
18972            .collect();
18973        if !usb_lines.is_empty() {
18974            out.push_str("=== Recent USB kernel events (journalctl) ===\n");
18975            for line in usb_lines {
18976                let _ = write!(out, "  {line}\n");
18977            }
18978        }
18979    } else {
18980        out.push_str("USB history via journalctl not available.\n");
18981    }
18982    Ok(out)
18983}
18984
18985// ── inspect_print_spooler ─────────────────────────────────────────────────────
18986
18987#[cfg(windows)]
18988fn inspect_print_spooler() -> Result<String, String> {
18989    let script = r#"
18990$result = [System.Text.StringBuilder]::new()
18991
18992$svc = Get-Service Spooler -ErrorAction SilentlyContinue
18993$result.AppendLine("=== Print Spooler Service ===") | Out-Null
18994if ($svc) {
18995    $result.AppendLine("  Status     : $($svc.Status)") | Out-Null
18996    $result.AppendLine("  Start Type : $($svc.StartType)") | Out-Null
18997} else {
18998    $result.AppendLine("  Spooler service not found.") | Out-Null
18999}
19000
19001# PrintNightmare mitigations (CVE-2021-34527)
19002$result.AppendLine("") | Out-Null
19003$result.AppendLine("=== PrintNightmare Hardening (CVE-2021-34527) ===") | Out-Null
19004try {
19005    $val = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Print' -Name RpcAuthnLevelPrivacyEnabled -ErrorAction SilentlyContinue).RpcAuthnLevelPrivacyEnabled
19006    if ($val -eq 1) {
19007        $result.AppendLine("  RpcAuthnLevelPrivacyEnabled: 1 — HARDENED (good)") | Out-Null
19008    } else {
19009        $result.AppendLine("  RpcAuthnLevelPrivacyEnabled: $val — NOT hardened") | Out-Null
19010        $result.AppendLine("  FINDING: PrintNightmare RPC mitigation not applied. Set to 1 via registry.") | Out-Null
19011    }
19012} catch { $result.AppendLine("  Mitigation key not readable: $_") | Out-Null }
19013
19014try {
19015    $pnpPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint'
19016    if (Test-Path $pnpPath) {
19017        $pnp = Get-ItemProperty $pnpPath -ErrorAction SilentlyContinue
19018        $result.AppendLine("  RestrictDriverInstallationToAdministrators: $($pnp.RestrictDriverInstallationToAdministrators)") | Out-Null
19019        $result.AppendLine("  NoWarningNoElevationOnInstall              : $($pnp.NoWarningNoElevationOnInstall)") | Out-Null
19020        if ($pnp.NoWarningNoElevationOnInstall -eq 1) {
19021            $result.AppendLine("  FINDING: Point and Print allows silent driver install without elevation (risk).") | Out-Null
19022        }
19023    } else {
19024        $result.AppendLine("  No Point and Print policy (using Windows defaults).") | Out-Null
19025    }
19026} catch {}
19027
19028# Pending print jobs
19029$result.AppendLine("") | Out-Null
19030$result.AppendLine("=== Print Queue ===") | Out-Null
19031try {
19032    $jobs = Get-PrintJob -ErrorAction SilentlyContinue
19033    if ($jobs) {
19034        foreach ($j in $jobs | Select-Object -First 5) {
19035            $result.AppendLine("  $($j.DocumentName) — $($j.JobStatus)") | Out-Null
19036        }
19037    } else {
19038        $result.AppendLine("  No pending print jobs.") | Out-Null
19039    }
19040} catch {
19041    $result.AppendLine("  Print queue check requires elevation.") | Out-Null
19042}
19043
19044Write-Output $result.ToString().TrimEnd()
19045"#;
19046    let out = run_powershell(script)?;
19047    Ok(format!("Host inspection: print_spooler\n\n{out}"))
19048}
19049
19050#[cfg(not(windows))]
19051fn inspect_print_spooler() -> Result<String, String> {
19052    let mut out = String::from("Host inspection: print_spooler\n\n");
19053    if let Ok(o) = Command::new("lpstat").args(["-s"]).output() {
19054        let s = String::from_utf8_lossy(&o.stdout);
19055        if !s.trim().is_empty() {
19056            out.push_str("=== CUPS Status (lpstat -s) ===\n");
19057            out.push_str(s.trim_end());
19058            out.push('\n');
19059        }
19060    } else {
19061        out.push_str("CUPS not detected (lpstat not found).\n");
19062    }
19063    Ok(out)
19064}
19065
19066#[cfg(not(windows))]
19067fn inspect_defender_quarantine(_max_entries: usize) -> Result<String, String> {
19068    let mut out = String::from("Host inspection: defender_quarantine\n\n");
19069    out.push_str("Windows Defender is Windows-only.\n");
19070    // Check ClamAV on Linux/macOS
19071    if let Ok(o) = Command::new("clamscan").arg("--version").output() {
19072        if o.status.success() {
19073            out.push_str("\nClamAV detected. Run `clamscan -r --infected /path` for a scan.\n");
19074            if let Ok(log) = std::fs::read_to_string("/var/log/clamav/clamav.log") {
19075                out.push_str("\n=== ClamAV Recent Log ===\n");
19076                for line in log.lines().rev().take(20) {
19077                    let _ = write!(out, "  {line}\n");
19078                }
19079            }
19080        }
19081    } else {
19082        out.push_str("No AV tool detected (ClamAV not found).\n");
19083    }
19084    Ok(out)
19085}