Skip to main content

hematite/tools/
host_inspect.rs

1use serde_json::Value;
2use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7const DEFAULT_MAX_ENTRIES: usize = 10;
8const MAX_ENTRIES_CAP: usize = 25;
9const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
10
11pub async fn inspect_host(args: &Value) -> Result<String, String> {
12    let mut topic = args
13        .get("topic")
14        .and_then(|v| v.as_str())
15        .unwrap_or("summary")
16        .to_string();
17    let max_entries = parse_max_entries(args);
18    let filter = parse_name_filter(args).unwrap_or_default().to_lowercase();
19
20    // Topic Interceptor: Force ad_user for AD-related queries to resolve model variance
21    if (topic == "processes" || topic == "network" || topic == "summary")
22        && (filter.contains("ad")
23            || filter.contains("sid")
24            || filter.contains("administrator")
25            || filter.contains("domain"))
26    {
27        topic = "ad_user".to_string();
28    }
29
30    let result = match topic.as_str() {
31        "summary" => inspect_summary(max_entries),
32        "toolchains" => inspect_toolchains(),
33        "path" => inspect_path(max_entries),
34        "env_doctor" => inspect_env_doctor(max_entries),
35        "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
36        "network" => inspect_network(max_entries),
37        "lan_discovery" | "network_neighborhood" | "upnp" | "neighborhood" => {
38            inspect_lan_discovery(max_entries)
39        }
40        "audio" | "sound" | "microphone" | "speakers" | "speaker" | "mic" => {
41            inspect_audio(max_entries)
42        }
43        "bluetooth" | "bt" | "paired_devices" | "wireless_audio" => {
44            inspect_bluetooth(max_entries)
45        }
46        "camera" | "webcam" | "camera_privacy" => inspect_camera(max_entries),
47        "sign_in" | "windows_hello" | "hello" | "pin" | "login_issues" | "signin" => {
48            inspect_sign_in(max_entries)
49        }
50        "installer_health" | "installer" | "msi" | "msiexec" | "app_installer" => {
51            inspect_installer_health(max_entries)
52        }
53        "onedrive" | "sync_client" | "cloud_sync" | "known_folder_backup" => {
54            inspect_onedrive(max_entries)
55        }
56        "browser_health" | "browser" | "webview2" | "default_browser" => {
57            inspect_browser_health(max_entries)
58        }
59        "identity_auth"
60        | "office_auth"
61        | "m365_auth"
62        | "microsoft_365_auth"
63        | "auth_broker" => inspect_identity_auth(max_entries),
64        "outlook" | "outlook_health" | "ms_outlook" => inspect_outlook(max_entries),
65        "teams" | "ms_teams" | "teams_health" => inspect_teams(max_entries),
66        "windows_backup" | "backup" | "file_history" | "wbadmin" | "system_restore" => {
67            inspect_windows_backup(max_entries)
68        }
69        "search_index" | "windows_search" | "indexing" | "search" => {
70            inspect_search_index(max_entries)
71        }
72        "services" => inspect_services(parse_name_filter(args), max_entries),
73        "processes" => inspect_processes(parse_name_filter(args), max_entries),
74        "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
75        "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
76        "disk" => {
77            let path = resolve_optional_path(args)?;
78            inspect_disk(path, max_entries).await
79        }
80        "ports" => inspect_ports(parse_port_filter(args), max_entries),
81        "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
82        "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
83        "health_report" | "system_health" => inspect_health_report(),
84        "storage" => inspect_storage(max_entries),
85        "hardware" => inspect_hardware(),
86        "updates" | "windows_update" => inspect_updates(),
87        "security" | "antivirus" | "defender" => inspect_security(),
88        "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
89        "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
90        "battery" => inspect_battery(),
91        "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
92        "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
93        "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
94        "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
95        "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
96        "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
97        "vpn" => inspect_vpn(),
98        "proxy" | "proxy_settings" => inspect_proxy(),
99        "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
100        "traceroute" | "tracert" | "trace_route" | "trace" => {
101            let host = args
102                .get("host")
103                .and_then(|v| v.as_str())
104                .unwrap_or("8.8.8.8")
105                .to_string();
106            inspect_traceroute(&host, max_entries)
107        }
108        "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
109        "arp" | "arp_table" => inspect_arp(),
110        "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
111        "os_config" | "system_config" => inspect_os_config(),
112        "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
113        "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
114        "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
115        "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
116        "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
117            inspect_docker_filesystems(max_entries)
118        }
119        "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
120        "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
121        "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
122        "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
123        "git_config" | "git_global" => inspect_git_config(),
124        "databases" | "database" | "db_services" | "db" => inspect_databases(),
125        "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
126        "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
127        "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
128        "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
129        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
130        "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
131        "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
132        "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
133        "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
134        "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
135        "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
136        "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
137        "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
138        "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
139        "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
140        "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
141        "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
142        "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
143        "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
144        "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
145        "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
146        "repo_doctor" => {
147            let path = resolve_optional_path(args)?;
148            inspect_repo_doctor(path, max_entries)
149        }
150        "directory" => {
151            let raw_path = args
152                .get("path")
153                .and_then(|v| v.as_str())
154                .ok_or_else(|| {
155                    "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
156                        .to_string()
157                })?;
158            let resolved = resolve_path(raw_path)?;
159            inspect_directory("Directory", resolved, max_entries).await
160        }
161        "disk_benchmark" | "stress_test" | "io_intensity" => {
162            let path = resolve_optional_path(args)?;
163            inspect_disk_benchmark(path).await
164        }
165        "permissions" | "acl" | "access_control" => {
166            let path = resolve_optional_path(args)?;
167            inspect_permissions(path, max_entries)
168        }
169        "login_history" | "logon_history" | "user_logins" => {
170            inspect_login_history(max_entries)
171        }
172        "share_access" | "unc_access" | "remote_share" => {
173            let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
174            inspect_share_access(path)
175        }
176        "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
177        "thermal" | "throttling" | "overheating" => inspect_thermal(),
178        "activation" | "license_status" | "slmgr" => inspect_activation(),
179        "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
180        "ad_user" | "ad" | "domain_user" => {
181            let identity = parse_name_filter(args).unwrap_or_default();
182            inspect_ad_user(&identity)
183        }
184        "dns_lookup" | "dig" | "nslookup" => {
185            let name = parse_name_filter(args).unwrap_or_default();
186            let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("A");
187            inspect_dns_lookup(&name, record_type)
188        }
189        "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
190        "ip_config" | "ip_detail" => inspect_ip_config(),
191        "dhcp" | "dhcp_lease" | "lease" | "dhcp_detail" => inspect_dhcp(),
192        "mtu" | "path_mtu" | "pmtu" | "frame_size" | "mtu_discovery" => inspect_mtu(),
193        "ipv6" | "ipv6_status" | "ipv6_address" | "ipv6_prefix" | "ipv6_config" | "slaac" | "dhcpv6" => inspect_ipv6(),
194        "tcp_params" | "tcp_settings" | "tcp_autotuning" | "tcp_config" | "tcp_tuning" | "tcp_window" => inspect_tcp_params(),
195        "wlan_profiles" | "wifi_profiles" | "wireless_profiles" | "saved_wifi" | "saved_networks" => inspect_wlan_profiles(),
196        "ipsec" | "ipsec_sa" | "ipsec_policy" | "ipsec_rules" | "ipsec_tunnel" | "ike" => inspect_ipsec(),
197        "netbios" | "netbios_status" | "wins" | "nbtstat" | "netbios_config" => inspect_netbios(),
198        "nic_teaming" | "nic_team" | "teaming" | "lacp" | "bonding" | "link_aggregation" => inspect_nic_teaming(),
199        "snmp" | "snmp_agent" | "snmp_service" | "snmp_config" => inspect_snmp(),
200        "port_test" | "port_check" | "test_port" | "check_port" | "tcp_test" | "reachable" => {
201            let pt_host = args.get("host").and_then(|v| v.as_str()).map(|s| s.to_string());
202            let pt_port = args.get("port").and_then(|v| v.as_u64()).map(|p| p as u16);
203            inspect_port_test(pt_host.as_deref(), pt_port)
204        }
205        "network_profile" | "network_location" | "net_profile" | "network_category" => inspect_network_profile(),
206        "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
207        "display_config" | "display" | "monitor" | "monitors" | "resolution" | "refresh_rate" | "screen" => {
208            inspect_display_config(max_entries)
209        }
210        "ntp" | "time_sync" | "time_synchronization" | "clock_sync" | "w32tm" | "clock" => {
211            inspect_ntp()
212        }
213        "cpu_power" | "turbo_boost" | "cpu_frequency" | "cpu_freq" | "processor_power" | "boost" => {
214            inspect_cpu_power()
215        }
216        "credentials" | "credential_manager" | "saved_passwords" | "stored_credentials" => {
217            inspect_credentials(max_entries)
218        }
219        "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
220            inspect_tpm()
221        }
222        "latency" | "ping" | "ping_test" | "rtt" | "packet_loss" | "reachability" => {
223            inspect_latency()
224        }
225        "network_adapter" | "nic" | "nic_settings" | "adapter_settings" | "nic_offload" | "nic_advanced" => {
226            inspect_network_adapter()
227        }
228        "event_query" | "event_log" | "events" | "event_search" | "eventlog" => {
229            let event_id = args.get("event_id").and_then(|v| v.as_u64()).map(|n| n as u32);
230            let log_name = args.get("log").and_then(|v| v.as_str()).map(|s| s.to_string());
231            let source = args.get("source").and_then(|v| v.as_str()).map(|s| s.to_string());
232            let hours = args.get("hours").and_then(|v| v.as_u64()).unwrap_or(24) as u32;
233            let level = args.get("level").and_then(|v| v.as_str()).map(|s| s.to_string());
234            inspect_event_query(event_id, log_name.as_deref(), source.as_deref(), hours, level.as_deref(), max_entries)
235        }
236        "app_crashes" | "application_crashes" | "app_errors" | "application_errors" | "faulting_application" => {
237            let process_filter = args.get("process").and_then(|v| v.as_str()).map(|s| s.to_string());
238            inspect_app_crashes(process_filter.as_deref(), max_entries)
239        }
240        "mdm_enrollment" | "mdm" | "intune" | "intune_enrollment" | "device_enrollment" | "autopilot" => {
241            inspect_mdm_enrollment()
242        }
243        other => Err(format!(
244            "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, lan_discovery, audio, bluetooth, camera, sign_in, installer_health, onedrive, browser_health, identity_auth, outlook, teams, windows_backup, search_index, display_config, ntp, cpu_power, credentials, tpm, latency, network_adapter, dhcp, mtu, ipv6, tcp_params, wlan_profiles, ipsec, netbios, nic_teaming, snmp, port_test, network_profile, services, processes, desktop, downloads, directory, disk_benchmark, disk, ports, repo_doctor, log_check, startup_items, health_report, storage, hardware, updates, security, pending_reboot, disk_health, battery, recent_crashes, app_crashes, scheduled_tasks, dev_conflicts, connectivity, wifi, connections, vpn, proxy, firewall_rules, traceroute, dns_cache, arp, route_table, os_config, resource_load, env, hosts_file, docker, docker_filesystems, wsl, wsl_filesystems, ssh, installed_software, git_config, databases, user_accounts, audit_policy, shares, dns_servers, bitlocker, rdp, shadow_copies, pagefile, windows_features, printers, winrm, network_stats, udp_ports, gpo, certificates, integrity, domain, 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.",
245            other
246        )),
247
248    };
249
250    result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
251}
252
253fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
254    let Some(scope) = admin_sensitive_topic_scope(topic) else {
255        return body;
256    };
257    let lower = body.to_lowercase();
258    let privilege_limited = lower.contains("access denied")
259        || lower.contains("administrator privilege is required")
260        || lower.contains("administrator privileges required")
261        || lower.contains("requires administrator")
262        || lower.contains("requires elevation")
263        || lower.contains("non-admin session")
264        || lower.contains("could not be fully determined from this session");
265    if !privilege_limited || lower.contains("=== elevation note ===") {
266        return body;
267    }
268
269    let mut annotated = body;
270    annotated.push_str("\n=== Elevation note ===\n");
271    annotated.push_str("- Hematite should stay non-admin by default.\n");
272    annotated.push_str(
273        "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
274    );
275    annotated.push_str(&format!(
276        "- Rerun Hematite as Administrator only if you need a definitive {scope} answer.\n"
277    ));
278    annotated
279}
280
281fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
282    match topic {
283        "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
284            Some("TPM / Secure Boot / firmware")
285        }
286        "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
287        "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
288        "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
289        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
290        "windows_features" | "optional_features" | "installed_features" | "features" => {
291            Some("Windows Features")
292        }
293        "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
294        _ => None,
295    }
296}
297
298#[cfg(test)]
299mod privilege_hint_tests {
300    use super::annotate_privilege_limited_output;
301
302    #[test]
303    fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
304        let body = "Host inspection: network\nError: Access denied.\n".to_string();
305        let annotated = annotate_privilege_limited_output("network", body.clone());
306        assert_eq!(annotated, body);
307    }
308
309    #[test]
310    fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
311        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();
312        let annotated = annotate_privilege_limited_output("tpm", body);
313        assert!(annotated.contains("=== Elevation note ==="));
314        assert!(annotated.contains("stay non-admin by default"));
315        assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
316    }
317}
318
319#[cfg(test)]
320mod event_query_tests {
321    use super::is_event_query_no_results_message;
322
323    #[cfg(target_os = "windows")]
324    #[test]
325    fn treats_windows_no_results_message_as_empty_query() {
326        assert!(is_event_query_no_results_message(
327            "No events were found that match the specified selection criteria."
328        ));
329    }
330
331    #[cfg(target_os = "windows")]
332    #[test]
333    fn does_not_treat_real_errors_as_empty_query() {
334        assert!(!is_event_query_no_results_message("Access is denied."));
335    }
336}
337
338fn parse_max_entries(args: &Value) -> usize {
339    args.get("max_entries")
340        .and_then(|v| v.as_u64())
341        .map(|n| n as usize)
342        .unwrap_or(DEFAULT_MAX_ENTRIES)
343        .clamp(1, MAX_ENTRIES_CAP)
344}
345
346fn parse_port_filter(args: &Value) -> Option<u16> {
347    args.get("port")
348        .and_then(|v| v.as_u64())
349        .and_then(|n| u16::try_from(n).ok())
350}
351
352fn parse_name_filter(args: &Value) -> Option<String> {
353    args.get("name")
354        .and_then(|v| v.as_str())
355        .map(str::trim)
356        .filter(|value| !value.is_empty())
357        .map(|value| value.to_string())
358}
359
360fn parse_lookback_hours(args: &Value) -> Option<u32> {
361    args.get("lookback_hours")
362        .and_then(|v| v.as_u64())
363        .map(|n| n as u32)
364}
365
366fn parse_issue_text(args: &Value) -> Option<String> {
367    args.get("issue")
368        .and_then(|v| v.as_str())
369        .map(str::trim)
370        .filter(|value| !value.is_empty())
371        .map(|value| value.to_string())
372}
373
374#[cfg(target_os = "windows")]
375fn is_event_query_no_results_message(message: &str) -> bool {
376    let lower = message.to_ascii_lowercase();
377    lower.contains("no events were found")
378        || lower.contains("no events match the specified selection criteria")
379}
380
381fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
382    match args.get("path").and_then(|v| v.as_str()) {
383        Some(raw_path) => resolve_path(raw_path),
384        None => {
385            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
386        }
387    }
388}
389
390fn inspect_summary(max_entries: usize) -> Result<String, String> {
391    let current_dir =
392        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
393    let workspace_root = crate::tools::file_ops::workspace_root();
394    let workspace_mode = workspace_mode_label(&workspace_root);
395    let path_stats = analyze_path_env();
396    let toolchains = collect_toolchains();
397
398    let mut out = String::from("Host inspection: summary\n\n");
399    out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
400    out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
401    out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
402    out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
403    out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
404    out.push_str(&format!(
405        "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
406        path_stats.total_entries,
407        path_stats.unique_entries,
408        path_stats.duplicate_entries.len(),
409        path_stats.missing_entries.len()
410    ));
411
412    if toolchains.found.is_empty() {
413        out.push_str(
414            "- Toolchains found: none of the common developer tools were detected on PATH\n",
415        );
416    } else {
417        out.push_str("- Toolchains found:\n");
418        for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
419            out.push_str(&format!("  - {}: {}\n", label, version));
420        }
421        if toolchains.found.len() > max_entries.min(8) {
422            out.push_str(&format!(
423                "  - ... {} more found tools omitted\n",
424                toolchains.found.len() - max_entries.min(8)
425            ));
426        }
427    }
428
429    if !toolchains.missing.is_empty() {
430        out.push_str(&format!(
431            "- Common tools not detected on PATH: {}\n",
432            toolchains.missing.join(", ")
433        ));
434    }
435
436    for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
437        match path {
438            Some(path) if path.exists() => match count_top_level_items(&path) {
439                Ok(count) => out.push_str(&format!(
440                    "- {}: {} top-level items at {}\n",
441                    label,
442                    count,
443                    path.display()
444                )),
445                Err(e) => out.push_str(&format!(
446                    "- {}: exists at {} but could not inspect ({})\n",
447                    label,
448                    path.display(),
449                    e
450                )),
451            },
452            Some(path) => out.push_str(&format!(
453                "- {}: expected at {} but not found\n",
454                label,
455                path.display()
456            )),
457            None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
458        }
459    }
460
461    Ok(out.trim_end().to_string())
462}
463
464fn inspect_toolchains() -> Result<String, String> {
465    let report = collect_toolchains();
466    let mut out = String::from("Host inspection: toolchains\n\n");
467
468    if report.found.is_empty() {
469        out.push_str("- No common developer tools were detected on PATH.");
470    } else {
471        out.push_str("Detected developer tools:\n");
472        for (label, version) in report.found {
473            out.push_str(&format!("- {}: {}\n", label, version));
474        }
475    }
476
477    if !report.missing.is_empty() {
478        out.push_str("\nNot detected on PATH:\n");
479        for label in report.missing {
480            out.push_str(&format!("- {}\n", label));
481        }
482    }
483
484    Ok(out.trim_end().to_string())
485}
486
487fn inspect_path(max_entries: usize) -> Result<String, String> {
488    let path_stats = analyze_path_env();
489    let mut out = String::from("Host inspection: PATH\n\n");
490    out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
491    out.push_str(&format!(
492        "- Unique entries: {}\n",
493        path_stats.unique_entries
494    ));
495    out.push_str(&format!(
496        "- Duplicate entries: {}\n",
497        path_stats.duplicate_entries.len()
498    ));
499    out.push_str(&format!(
500        "- Missing paths: {}\n",
501        path_stats.missing_entries.len()
502    ));
503
504    out.push_str("\nPATH entries:\n");
505    for entry in path_stats.entries.iter().take(max_entries) {
506        out.push_str(&format!("- {}\n", entry));
507    }
508    if path_stats.entries.len() > max_entries {
509        out.push_str(&format!(
510            "- ... {} more entries omitted\n",
511            path_stats.entries.len() - max_entries
512        ));
513    }
514
515    if !path_stats.duplicate_entries.is_empty() {
516        out.push_str("\nDuplicate entries:\n");
517        for entry in path_stats.duplicate_entries.iter().take(max_entries) {
518            out.push_str(&format!("- {}\n", entry));
519        }
520        if path_stats.duplicate_entries.len() > max_entries {
521            out.push_str(&format!(
522                "- ... {} more duplicates omitted\n",
523                path_stats.duplicate_entries.len() - max_entries
524            ));
525        }
526    }
527
528    if !path_stats.missing_entries.is_empty() {
529        out.push_str("\nMissing directories:\n");
530        for entry in path_stats.missing_entries.iter().take(max_entries) {
531            out.push_str(&format!("- {}\n", entry));
532        }
533        if path_stats.missing_entries.len() > max_entries {
534            out.push_str(&format!(
535                "- ... {} more missing entries omitted\n",
536                path_stats.missing_entries.len() - max_entries
537            ));
538        }
539    }
540
541    Ok(out.trim_end().to_string())
542}
543
544fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
545    let path_stats = analyze_path_env();
546    let toolchains = collect_toolchains();
547    let package_managers = collect_package_managers();
548    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
549
550    let mut out = String::from("Host inspection: env_doctor\n\n");
551    out.push_str(&format!(
552        "- PATH health: {} duplicates, {} missing entries\n",
553        path_stats.duplicate_entries.len(),
554        path_stats.missing_entries.len()
555    ));
556    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
557    out.push_str(&format!(
558        "- Package managers found: {}\n",
559        package_managers.found.len()
560    ));
561
562    if !package_managers.found.is_empty() {
563        out.push_str("\nPackage managers:\n");
564        for (label, version) in package_managers.found.iter().take(max_entries) {
565            out.push_str(&format!("- {}: {}\n", label, version));
566        }
567        if package_managers.found.len() > max_entries {
568            out.push_str(&format!(
569                "- ... {} more package managers omitted\n",
570                package_managers.found.len() - max_entries
571            ));
572        }
573    }
574
575    if !path_stats.duplicate_entries.is_empty() {
576        out.push_str("\nDuplicate PATH entries:\n");
577        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
578            out.push_str(&format!("- {}\n", entry));
579        }
580        if path_stats.duplicate_entries.len() > max_entries.min(5) {
581            out.push_str(&format!(
582                "- ... {} more duplicate entries omitted\n",
583                path_stats.duplicate_entries.len() - max_entries.min(5)
584            ));
585        }
586    }
587
588    if !path_stats.missing_entries.is_empty() {
589        out.push_str("\nMissing PATH entries:\n");
590        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
591            out.push_str(&format!("- {}\n", entry));
592        }
593        if path_stats.missing_entries.len() > max_entries.min(5) {
594            out.push_str(&format!(
595                "- ... {} more missing entries omitted\n",
596                path_stats.missing_entries.len() - max_entries.min(5)
597            ));
598        }
599    }
600
601    if !findings.is_empty() {
602        out.push_str("\nFindings:\n");
603        for finding in findings.iter().take(max_entries.max(5)) {
604            out.push_str(&format!("- {}\n", finding));
605        }
606        if findings.len() > max_entries.max(5) {
607            out.push_str(&format!(
608                "- ... {} more findings omitted\n",
609                findings.len() - max_entries.max(5)
610            ));
611        }
612    } else {
613        out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
614    }
615
616    out.push_str(
617        "\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.",
618    );
619
620    Ok(out.trim_end().to_string())
621}
622
623#[derive(Clone, Copy, Debug, Eq, PartialEq)]
624enum FixPlanKind {
625    EnvPath,
626    PortConflict,
627    LmStudio,
628    DriverInstall,
629    GroupPolicy,
630    FirewallRule,
631    SshKey,
632    WslSetup,
633    ServiceConfig,
634    WindowsActivation,
635    RegistryEdit,
636    ScheduledTaskCreate,
637    DiskCleanup,
638    DnsResolution,
639    Generic,
640}
641
642async fn inspect_fix_plan(
643    issue: Option<String>,
644    port_filter: Option<u16>,
645    max_entries: usize,
646) -> Result<String, String> {
647    let issue = issue.unwrap_or_else(|| {
648        "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
649            .to_string()
650    });
651    let plan_kind = classify_fix_plan_kind(&issue, port_filter);
652    match plan_kind {
653        FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
654        FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
655        FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
656        FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
657        FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
658        FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
659        FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
660        FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
661        FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
662        FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
663        FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
664        FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
665        FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
666        FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
667        FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
668    }
669}
670
671fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
672    let lower = issue.to_ascii_lowercase();
673    // FirewallRule must be checked before PortConflict — "open port 80 in the firewall"
674    // is firewall rule creation, not a port ownership conflict.
675    if lower.contains("firewall rule")
676        || lower.contains("inbound rule")
677        || lower.contains("outbound rule")
678        || (lower.contains("firewall")
679            && (lower.contains("allow")
680                || lower.contains("block")
681                || lower.contains("create")
682                || lower.contains("open")))
683    {
684        FixPlanKind::FirewallRule
685    } else if port_filter.is_some()
686        || lower.contains("port ")
687        || lower.contains("address already in use")
688        || lower.contains("already in use")
689        || lower.contains("what owns port")
690        || lower.contains("listening on port")
691    {
692        FixPlanKind::PortConflict
693    } else if lower.contains("lm studio")
694        || lower.contains("localhost:1234")
695        || lower.contains("/v1/models")
696        || lower.contains("no coding model loaded")
697        || lower.contains("embedding model")
698        || lower.contains("server on port 1234")
699        || lower.contains("runtime refresh")
700    {
701        FixPlanKind::LmStudio
702    } else if lower.contains("driver")
703        || lower.contains("gpu driver")
704        || lower.contains("nvidia driver")
705        || lower.contains("amd driver")
706        || lower.contains("install driver")
707        || lower.contains("update driver")
708    {
709        FixPlanKind::DriverInstall
710    } else if lower.contains("group policy")
711        || lower.contains("gpedit")
712        || lower.contains("local policy")
713        || lower.contains("secpol")
714        || lower.contains("administrative template")
715    {
716        FixPlanKind::GroupPolicy
717    } else if lower.contains("ssh key")
718        || lower.contains("ssh-keygen")
719        || lower.contains("generate ssh")
720        || lower.contains("authorized_keys")
721        || lower.contains("id_rsa")
722        || lower.contains("id_ed25519")
723    {
724        FixPlanKind::SshKey
725    } else if lower.contains("wsl")
726        || lower.contains("windows subsystem for linux")
727        || lower.contains("install ubuntu")
728        || lower.contains("install linux on windows")
729        || lower.contains("wsl2")
730    {
731        FixPlanKind::WslSetup
732    } else if lower.contains("service")
733        && (lower.contains("start ")
734            || lower.contains("stop ")
735            || lower.contains("restart ")
736            || lower.contains("enable ")
737            || lower.contains("disable ")
738            || lower.contains("configure service"))
739    {
740        FixPlanKind::ServiceConfig
741    } else if lower.contains("activate windows")
742        || lower.contains("windows activation")
743        || lower.contains("product key")
744        || lower.contains("kms")
745        || lower.contains("not activated")
746    {
747        FixPlanKind::WindowsActivation
748    } else if lower.contains("registry")
749        || lower.contains("regedit")
750        || lower.contains("hklm")
751        || lower.contains("hkcu")
752        || lower.contains("reg add")
753        || lower.contains("reg delete")
754        || lower.contains("registry key")
755    {
756        FixPlanKind::RegistryEdit
757    } else if lower.contains("scheduled task")
758        || lower.contains("task scheduler")
759        || lower.contains("schtasks")
760        || lower.contains("create task")
761        || lower.contains("run on startup")
762        || lower.contains("run on schedule")
763        || lower.contains("cron")
764    {
765        FixPlanKind::ScheduledTaskCreate
766    } else if lower.contains("disk cleanup")
767        || lower.contains("free up disk")
768        || lower.contains("free up space")
769        || lower.contains("clear cache")
770        || lower.contains("disk full")
771        || lower.contains("low disk space")
772        || lower.contains("reclaim space")
773    {
774        FixPlanKind::DiskCleanup
775    } else if lower.contains("cargo")
776        || lower.contains("rustc")
777        || lower.contains("path")
778        || lower.contains("package manager")
779        || lower.contains("package managers")
780        || lower.contains("toolchain")
781        || lower.contains("winget")
782        || lower.contains("choco")
783        || lower.contains("scoop")
784        || lower.contains("python")
785        || lower.contains("node")
786    {
787        FixPlanKind::EnvPath
788    } else if lower.contains("dns ")
789        || lower.contains("nameserver")
790        || lower.contains("cannot resolve")
791        || lower.contains("nslookup")
792        || lower.contains("flushdns")
793    {
794        FixPlanKind::DnsResolution
795    } else {
796        FixPlanKind::Generic
797    }
798}
799
800fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
801    let path_stats = analyze_path_env();
802    let toolchains = collect_toolchains();
803    let package_managers = collect_package_managers();
804    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
805    let found_tools = toolchains
806        .found
807        .iter()
808        .map(|(label, _)| label.as_str())
809        .collect::<HashSet<_>>();
810    let found_managers = package_managers
811        .found
812        .iter()
813        .map(|(label, _)| label.as_str())
814        .collect::<HashSet<_>>();
815
816    let mut out = String::from("Host inspection: fix_plan\n\n");
817    out.push_str(&format!("- Requested issue: {}\n", issue));
818    out.push_str("- Fix-plan type: environment/path\n");
819    out.push_str(&format!(
820        "- PATH health: {} duplicates, {} missing entries\n",
821        path_stats.duplicate_entries.len(),
822        path_stats.missing_entries.len()
823    ));
824    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
825    out.push_str(&format!(
826        "- Package managers found: {}\n",
827        package_managers.found.len()
828    ));
829
830    out.push_str("\nLikely causes:\n");
831    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
832        out.push_str(
833            "- 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",
834        );
835    }
836    if path_stats.duplicate_entries.is_empty()
837        && path_stats.missing_entries.is_empty()
838        && !findings.is_empty()
839    {
840        for finding in findings.iter().take(max_entries.max(4)) {
841            out.push_str(&format!("- {}\n", finding));
842        }
843    } else {
844        if !path_stats.duplicate_entries.is_empty() {
845            out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
846        }
847        if !path_stats.missing_entries.is_empty() {
848            out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
849        }
850    }
851    if found_tools.contains("node")
852        && !found_managers.contains("npm")
853        && !found_managers.contains("pnpm")
854    {
855        out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
856    }
857    if found_tools.contains("python")
858        && !found_managers.contains("pip")
859        && !found_managers.contains("uv")
860        && !found_managers.contains("pipx")
861    {
862        out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
863    }
864
865    out.push_str("\nFix plan:\n");
866    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");
867    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
868        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");
869    } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
870        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");
871    }
872    if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
873        out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
874    }
875    if found_tools.contains("node")
876        && !found_managers.contains("npm")
877        && !found_managers.contains("pnpm")
878    {
879        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");
880    }
881    if found_tools.contains("python")
882        && !found_managers.contains("pip")
883        && !found_managers.contains("uv")
884        && !found_managers.contains("pipx")
885    {
886        out.push_str("- Repair Python or install a Python package manager explicitly. `py -m ensurepip --upgrade` is the least-invasive first check on Windows.\n");
887    }
888
889    if !path_stats.duplicate_entries.is_empty() {
890        out.push_str("\nExample duplicate PATH rows:\n");
891        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
892            out.push_str(&format!("- {}\n", entry));
893        }
894    }
895    if !path_stats.missing_entries.is_empty() {
896        out.push_str("\nExample missing PATH rows:\n");
897        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
898            out.push_str(&format!("- {}\n", entry));
899        }
900    }
901
902    out.push_str(
903        "\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.",
904    );
905    Ok(out.trim_end().to_string())
906}
907
908fn inspect_port_fix_plan(
909    issue: &str,
910    port_filter: Option<u16>,
911    max_entries: usize,
912) -> Result<String, String> {
913    let requested_port = port_filter.or_else(|| first_port_in_text(issue));
914    let listeners = collect_listening_ports().unwrap_or_default();
915    let mut matching = listeners;
916    if let Some(port) = requested_port {
917        matching.retain(|entry| entry.port == port);
918    }
919    let processes = collect_processes().unwrap_or_default();
920
921    let mut out = String::from("Host inspection: fix_plan\n\n");
922    out.push_str(&format!("- Requested issue: {}\n", issue));
923    out.push_str("- Fix-plan type: port_conflict\n");
924    if let Some(port) = requested_port {
925        out.push_str(&format!("- Requested port: {}\n", port));
926    } else {
927        out.push_str("- Requested port: not parsed from the issue text\n");
928    }
929    out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
930
931    if !matching.is_empty() {
932        out.push_str("\nCurrent listeners:\n");
933        for entry in matching.iter().take(max_entries.min(5)) {
934            let process_name = entry
935                .pid
936                .as_deref()
937                .and_then(|pid| pid.parse::<u32>().ok())
938                .and_then(|pid| {
939                    processes
940                        .iter()
941                        .find(|process| process.pid == pid)
942                        .map(|process| process.name.as_str())
943                })
944                .unwrap_or("unknown");
945            let pid = entry.pid.as_deref().unwrap_or("unknown");
946            out.push_str(&format!(
947                "- {} {} ({}) pid {} process {}\n",
948                entry.protocol, entry.local, entry.state, pid, process_name
949            ));
950        }
951    }
952
953    out.push_str("\nFix plan:\n");
954    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");
955    if !matching.is_empty() {
956        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");
957    } else {
958        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");
959    }
960    out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
961    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");
962    out.push_str(
963        "\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.",
964    );
965    Ok(out.trim_end().to_string())
966}
967
968async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
969    let config = crate::agent::config::load_config();
970    let configured_api = config
971        .api_url
972        .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
973    let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
974    let reachability = probe_http_endpoint(&models_url).await;
975    let embed_model = detect_loaded_embed_model(&configured_api).await;
976
977    let mut out = String::from("Host inspection: fix_plan\n\n");
978    out.push_str(&format!("- Requested issue: {}\n", issue));
979    out.push_str("- Fix-plan type: lm_studio\n");
980    out.push_str(&format!("- Configured API URL: {}\n", configured_api));
981    out.push_str(&format!("- Probe URL: {}\n", models_url));
982    match &reachability {
983        EndpointProbe::Reachable(status) => {
984            out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
985        }
986        EndpointProbe::Unreachable(detail) => {
987            out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
988        }
989    }
990    out.push_str(&format!(
991        "- Embedding model loaded: {}\n",
992        embed_model.as_deref().unwrap_or("none detected")
993    ));
994
995    out.push_str("\nFix plan:\n");
996    match reachability {
997        EndpointProbe::Reachable(_) => {
998            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");
999        }
1000        EndpointProbe::Unreachable(_) => {
1001            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");
1002        }
1003    }
1004    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");
1005    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");
1006    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");
1007    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");
1008    if let Some(model) = embed_model {
1009        out.push_str(&format!(
1010            "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
1011            model
1012        ));
1013    }
1014    if max_entries > 0 {
1015        out.push_str(
1016            "\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.",
1017        );
1018    }
1019    Ok(out.trim_end().to_string())
1020}
1021
1022fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1023    // Read GPU info from the hardware topic output for grounding
1024    #[cfg(target_os = "windows")]
1025    let gpu_info = {
1026        let out = Command::new("powershell")
1027            .args([
1028                "-NoProfile",
1029                "-NonInteractive",
1030                "-Command",
1031                "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1032            ])
1033            .output()
1034            .ok()
1035            .and_then(|o| String::from_utf8(o.stdout).ok())
1036            .unwrap_or_default();
1037        out.trim().to_string()
1038    };
1039    #[cfg(not(target_os = "windows"))]
1040    let gpu_info = String::from("(GPU detection not available on this platform)");
1041
1042    let mut out = String::from("Host inspection: fix_plan\n\n");
1043    out.push_str(&format!("- Requested issue: {}\n", issue));
1044    out.push_str("- Fix-plan type: driver_install\n");
1045    if !gpu_info.is_empty() {
1046        out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
1047    }
1048    out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1049    out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1050    out.push_str(
1051        "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1052    );
1053    out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1054    out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1055    out.push_str("   - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1056    out.push_str("   - AMD: amd.com/support (use Auto-Detect tool)\n");
1057    out.push_str("   - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1058    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");
1059    out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1060    out.push_str("\nVerification:\n");
1061    out.push_str("- After reboot, run in PowerShell:\n  Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1062    out.push_str("- The DriverVersion should match what you installed.\n");
1063    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.");
1064    Ok(out.trim_end().to_string())
1065}
1066
1067fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1068    // Check Windows edition — Group Policy editor is not available on Home editions
1069    #[cfg(target_os = "windows")]
1070    let edition = {
1071        Command::new("powershell")
1072            .args([
1073                "-NoProfile",
1074                "-NonInteractive",
1075                "-Command",
1076                "(Get-CimInstance Win32_OperatingSystem).Caption",
1077            ])
1078            .output()
1079            .ok()
1080            .and_then(|o| String::from_utf8(o.stdout).ok())
1081            .unwrap_or_default()
1082            .trim()
1083            .to_string()
1084    };
1085    #[cfg(not(target_os = "windows"))]
1086    let edition = String::from("(Windows edition detection not available)");
1087
1088    let is_home = edition.to_lowercase().contains("home");
1089
1090    let mut out = String::from("Host inspection: fix_plan\n\n");
1091    out.push_str(&format!("- Requested issue: {}\n", issue));
1092    out.push_str("- Fix-plan type: group_policy\n");
1093    out.push_str(&format!(
1094        "- Windows edition detected: {}\n",
1095        if edition.is_empty() {
1096            "unknown".to_string()
1097        } else {
1098            edition.clone()
1099        }
1100    ));
1101
1102    if is_home {
1103        out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1104        out.push_str("Options on Home edition:\n");
1105        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");
1106        out.push_str(
1107            "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1108        );
1109        out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1110    } else {
1111        out.push_str("\nFix plan — Editing Local Group Policy:\n");
1112        out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1113        out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1114        out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1115        out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1116        out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1117        out.push_str("6. To force immediate application, run in an elevated PowerShell:\n  gpupdate /force\n");
1118    }
1119    out.push_str("\nVerification:\n");
1120    out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1121    out.push_str(
1122        "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1123    );
1124    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.");
1125    Ok(out.trim_end().to_string())
1126}
1127
1128fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1129    #[cfg(target_os = "windows")]
1130    let profile_state = {
1131        Command::new("powershell")
1132            .args([
1133                "-NoProfile",
1134                "-NonInteractive",
1135                "-Command",
1136                "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1137            ])
1138            .output()
1139            .ok()
1140            .and_then(|o| String::from_utf8(o.stdout).ok())
1141            .unwrap_or_default()
1142            .trim()
1143            .to_string()
1144    };
1145    #[cfg(not(target_os = "windows"))]
1146    let profile_state = String::new();
1147
1148    let mut out = String::from("Host inspection: fix_plan\n\n");
1149    out.push_str(&format!("- Requested issue: {}\n", issue));
1150    out.push_str("- Fix-plan type: firewall_rule\n");
1151    if !profile_state.is_empty() {
1152        out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
1153    }
1154    out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1155    out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1156    out.push_str("  New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1157    out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1158    out.push_str("  New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1159    out.push_str("\nTo ALLOW an application through the firewall:\n");
1160    out.push_str("  New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1161    out.push_str("\nTo REMOVE a rule you created:\n");
1162    out.push_str("  Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1163    out.push_str("\nTo see existing custom rules:\n");
1164    out.push_str("  Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1165    out.push_str("\nVerification:\n");
1166    out.push_str("- After creating the rule, test reachability from another machine or use:\n  Test-NetConnection -ComputerName localhost -Port 8080\n");
1167    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.");
1168    Ok(out.trim_end().to_string())
1169}
1170
1171fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1172    let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1173    let ssh_dir = home.join(".ssh");
1174    let has_ssh_dir = ssh_dir.exists();
1175    let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1176    let has_rsa = ssh_dir.join("id_rsa").exists();
1177    let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1178
1179    let mut out = String::from("Host inspection: fix_plan\n\n");
1180    out.push_str(&format!("- Requested issue: {}\n", issue));
1181    out.push_str("- Fix-plan type: ssh_key\n");
1182    out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
1183    out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1184    out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1185    out.push_str(&format!(
1186        "- authorized_keys found: {}\n",
1187        has_authorized_keys
1188    ));
1189
1190    if has_ed25519 {
1191        out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1192    }
1193
1194    out.push_str("\nFix plan — Generating an SSH key pair:\n");
1195    out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1196    out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1197    out.push_str("   ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1198    out.push_str(
1199        "   - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1200    );
1201    out.push_str("   - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1202    out.push_str("3. Start the SSH agent and add your key:\n");
1203    out.push_str("   # Windows (PowerShell, run as Admin once to enable the service):\n");
1204    out.push_str("   Set-Service -Name ssh-agent -StartupType Automatic\n");
1205    out.push_str("   Start-Service ssh-agent\n");
1206    out.push_str("   # Then add the key (normal PowerShell):\n");
1207    out.push_str("   ssh-add ~/.ssh/id_ed25519\n");
1208    out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1209    out.push_str("   # Print your public key:\n");
1210    out.push_str("   cat ~/.ssh/id_ed25519.pub\n");
1211    out.push_str("   # On the target server, append it:\n");
1212    out.push_str("   echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1213    out.push_str("   chmod 600 ~/.ssh/authorized_keys\n");
1214    out.push_str("5. Test the connection:\n");
1215    out.push_str("   ssh user@server-address\n");
1216    out.push_str("\nFor GitHub/GitLab:\n");
1217    out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1218    out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1219    out.push_str("- Test: ssh -T git@github.com\n");
1220    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.");
1221    Ok(out.trim_end().to_string())
1222}
1223
1224fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1225    #[cfg(target_os = "windows")]
1226    let wsl_status = {
1227        let out = Command::new("wsl")
1228            .args(["--status"])
1229            .output()
1230            .ok()
1231            .and_then(|o| {
1232                let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1233                let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1234                Some(format!("{}{}", stdout, stderr))
1235            })
1236            .unwrap_or_default();
1237        out.trim().to_string()
1238    };
1239    #[cfg(not(target_os = "windows"))]
1240    let wsl_status = String::new();
1241
1242    let wsl_installed =
1243        !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1244
1245    let mut out = String::from("Host inspection: fix_plan\n\n");
1246    out.push_str(&format!("- Requested issue: {}\n", issue));
1247    out.push_str("- Fix-plan type: wsl_setup\n");
1248    out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1249    if !wsl_status.is_empty() {
1250        out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1251    }
1252
1253    if wsl_installed {
1254        out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1255        out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1256        out.push_str("   Available distros: wsl --list --online\n");
1257        out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1258        out.push_str("3. Create your Linux username and password when prompted.\n");
1259    } else {
1260        out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1261        out.push_str("1. Open PowerShell as Administrator.\n");
1262        out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1263        out.push_str("   wsl --install\n");
1264        out.push_str("   (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1265        out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1266        out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1267        out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1268        out.push_str("   wsl --set-default-version 2\n");
1269        out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1270        out.push_str("   wsl --install -d Debian\n");
1271        out.push_str("   wsl --list --online   # to see all available distros\n");
1272    }
1273    out.push_str("\nVerification:\n");
1274    out.push_str("- Run: wsl --list --verbose\n");
1275    out.push_str("- You should see your distro with State: Running and Version: 2\n");
1276    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.");
1277    Ok(out.trim_end().to_string())
1278}
1279
1280fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1281    let lower = issue.to_ascii_lowercase();
1282    // Extract service name hints from the issue text
1283    let service_hint = if lower.contains("ssh") {
1284        Some("sshd")
1285    } else if lower.contains("mysql") {
1286        Some("MySQL80")
1287    } else if lower.contains("postgres") || lower.contains("postgresql") {
1288        Some("postgresql")
1289    } else if lower.contains("redis") {
1290        Some("Redis")
1291    } else if lower.contains("nginx") {
1292        Some("nginx")
1293    } else if lower.contains("apache") {
1294        Some("Apache2.4")
1295    } else {
1296        None
1297    };
1298
1299    #[cfg(target_os = "windows")]
1300    let service_state = if let Some(svc) = service_hint {
1301        Command::new("powershell")
1302            .args([
1303                "-NoProfile",
1304                "-NonInteractive",
1305                "-Command",
1306                &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1307            ])
1308            .output()
1309            .ok()
1310            .and_then(|o| String::from_utf8(o.stdout).ok())
1311            .unwrap_or_default()
1312            .trim()
1313            .to_string()
1314    } else {
1315        String::new()
1316    };
1317    #[cfg(not(target_os = "windows"))]
1318    let service_state = String::new();
1319
1320    let mut out = String::from("Host inspection: fix_plan\n\n");
1321    out.push_str(&format!("- Requested issue: {}\n", issue));
1322    out.push_str("- Fix-plan type: service_config\n");
1323    if let Some(svc) = service_hint {
1324        out.push_str(&format!("- Service detected in request: {}\n", svc));
1325    }
1326    if !service_state.is_empty() {
1327        out.push_str(&format!("- Current state: {}\n", service_state));
1328    }
1329
1330    out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1331    out.push_str("\nStart a service:\n");
1332    out.push_str("  Start-Service -Name \"ServiceName\"\n");
1333    out.push_str("\nStop a service:\n");
1334    out.push_str("  Stop-Service -Name \"ServiceName\"\n");
1335    out.push_str("\nRestart a service:\n");
1336    out.push_str("  Restart-Service -Name \"ServiceName\"\n");
1337    out.push_str("\nEnable a service to start automatically:\n");
1338    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1339    out.push_str("\nDisable a service (stops it from auto-starting):\n");
1340    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1341    out.push_str("\nFind the exact service name:\n");
1342    out.push_str("  Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1343    out.push_str("\nVerification:\n");
1344    out.push_str("  Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1345    if let Some(svc) = service_hint {
1346        out.push_str(&format!(
1347            "\nFor your detected service ({}):\n  Get-Service -Name '{}'\n",
1348            svc, svc
1349        ));
1350    }
1351    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.");
1352    Ok(out.trim_end().to_string())
1353}
1354
1355fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1356    #[cfg(target_os = "windows")]
1357    let activation_status = {
1358        Command::new("powershell")
1359            .args([
1360                "-NoProfile",
1361                "-NonInteractive",
1362                "-Command",
1363                "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 + ')' })\" }",
1364            ])
1365            .output()
1366            .ok()
1367            .and_then(|o| String::from_utf8(o.stdout).ok())
1368            .unwrap_or_default()
1369            .trim()
1370            .to_string()
1371    };
1372    #[cfg(not(target_os = "windows"))]
1373    let activation_status = String::new();
1374
1375    let is_licensed = activation_status.to_lowercase().contains("licensed")
1376        && !activation_status.to_lowercase().contains("not licensed");
1377
1378    let mut out = String::from("Host inspection: fix_plan\n\n");
1379    out.push_str(&format!("- Requested issue: {}\n", issue));
1380    out.push_str("- Fix-plan type: windows_activation\n");
1381    if !activation_status.is_empty() {
1382        out.push_str(&format!(
1383            "- Current activation state:\n{}\n",
1384            activation_status
1385        ));
1386    }
1387
1388    if is_licensed {
1389        out.push_str(
1390            "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1391        );
1392        out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1393        out.push_str("   (Forces an online activation attempt)\n");
1394        out.push_str("2. Check activation details: slmgr /dli\n");
1395    } else {
1396        out.push_str("\nFix plan — Activating Windows:\n");
1397        out.push_str("1. Check your current status first:\n");
1398        out.push_str("   slmgr /dli   (basic info)\n");
1399        out.push_str("   slmgr /dlv   (detailed — shows remaining rearms, grace period)\n");
1400        out.push_str("\n2. If you have a retail product key:\n");
1401        out.push_str("   slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX   (install key)\n");
1402        out.push_str("   slmgr /ato                                   (activate online)\n");
1403        out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1404        out.push_str("   - Go to Settings → System → Activation\n");
1405        out.push_str("   - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1406        out.push_str("   - Sign in with the Microsoft account that holds the license\n");
1407        out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1408        out.push_str("   - Contact your IT department for the KMS server address\n");
1409        out.push_str("   - Set KMS host: slmgr /skms kms.yourorg.com\n");
1410        out.push_str("   - Activate:    slmgr /ato\n");
1411    }
1412    out.push_str("\nVerification:\n");
1413    out.push_str("  slmgr /dli   — should show 'License Status: Licensed'\n");
1414    out.push_str("  Or: Settings → System → Activation → 'Windows is activated'\n");
1415    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.");
1416    Ok(out.trim_end().to_string())
1417}
1418
1419fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1420    let mut out = String::from("Host inspection: fix_plan\n\n");
1421    out.push_str(&format!("- Requested issue: {}\n", issue));
1422    out.push_str("- Fix-plan type: registry_edit\n");
1423    out.push_str(
1424        "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1425    );
1426    out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1427    out.push_str("\n1. Back up before you touch anything:\n");
1428    out.push_str("   # Export the key you're about to change (PowerShell):\n");
1429    out.push_str("   reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1430    out.push_str("   # Or export the whole registry (takes a while):\n");
1431    out.push_str("   reg export HKLM C:\\backup\\HKLM_full.reg\n");
1432    out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1433    out.push_str("   Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1434    out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1435    out.push_str(
1436        "   Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1437    );
1438    out.push_str("\n4. Create a new key:\n");
1439    out.push_str("   New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1440    out.push_str("\n5. Delete a value:\n");
1441    out.push_str("   Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1442    out.push_str("\n6. Restore from backup if something breaks:\n");
1443    out.push_str("   reg import C:\\backup\\MyKey_backup.reg\n");
1444    out.push_str("\nCommon registry hives:\n");
1445    out.push_str("  HKLM = HKEY_LOCAL_MACHINE  (machine-wide, requires Admin)\n");
1446    out.push_str("  HKCU = HKEY_CURRENT_USER   (current user, no elevation needed)\n");
1447    out.push_str("  HKCR = HKEY_CLASSES_ROOT    (file associations)\n");
1448    out.push_str("\nVerification:\n");
1449    out.push_str("  Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1450    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.");
1451    Ok(out.trim_end().to_string())
1452}
1453
1454fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1455    let mut out = String::from("Host inspection: fix_plan\n\n");
1456    out.push_str(&format!("- Requested issue: {}\n", issue));
1457    out.push_str("- Fix-plan type: scheduled_task_create\n");
1458    out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1459    out.push_str("\nExample: Run a script at 9 AM every day\n");
1460    out.push_str("  $action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1461    out.push_str("  $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1462    out.push_str("  Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1463    out.push_str("\nExample: Run at Windows startup\n");
1464    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1465    out.push_str("  Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1466    out.push_str("\nExample: Run at user logon\n");
1467    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1468    out.push_str(
1469        "  Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1470    );
1471    out.push_str("\nExample: Run every 30 minutes\n");
1472    out.push_str("  $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1473    out.push_str("\nView all tasks:\n");
1474    out.push_str("  Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1475    out.push_str("\nDelete a task:\n");
1476    out.push_str("  Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1477    out.push_str("\nRun a task immediately:\n");
1478    out.push_str("  Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1479    out.push_str("\nVerification:\n");
1480    out.push_str("  Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1481    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.");
1482    Ok(out.trim_end().to_string())
1483}
1484
1485fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1486    #[cfg(target_os = "windows")]
1487    let disk_info = {
1488        Command::new("powershell")
1489            .args([
1490                "-NoProfile",
1491                "-NonInteractive",
1492                "-Command",
1493                "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\" }",
1494            ])
1495            .output()
1496            .ok()
1497            .and_then(|o| String::from_utf8(o.stdout).ok())
1498            .unwrap_or_default()
1499            .trim()
1500            .to_string()
1501    };
1502    #[cfg(not(target_os = "windows"))]
1503    let disk_info = String::new();
1504
1505    let mut out = String::from("Host inspection: fix_plan\n\n");
1506    out.push_str(&format!("- Requested issue: {}\n", issue));
1507    out.push_str("- Fix-plan type: disk_cleanup\n");
1508    if !disk_info.is_empty() {
1509        out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1510    }
1511    out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1512    out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1513    out.push_str("   cleanmgr /sageset:1    (configure what to clean)\n");
1514    out.push_str("   cleanmgr /sagerun:1    (run the cleanup)\n");
1515    out.push_str("   Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1516    out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1517    out.push_str("   Stop-Service wuauserv\n");
1518    out.push_str("   Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1519    out.push_str("   Start-Service wuauserv\n");
1520    out.push_str("\n3. Clear Windows Temp folder:\n");
1521    out.push_str("   Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1522    out.push_str(
1523        "   Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1524    );
1525    out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1526    out.push_str("   - Rust build artifacts: cargo clean  (inside each project)\n");
1527    out.push_str("   - npm cache:  npm cache clean --force\n");
1528    out.push_str("   - pip cache:  pip cache purge\n");
1529    out.push_str(
1530        "   - Docker:     docker system prune -a  (removes all unused images/containers)\n",
1531    );
1532    out.push_str("   - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force  (will redownload on next build)\n");
1533    out.push_str("\n5. Check for large files:\n");
1534    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");
1535    out.push_str("\nVerification:\n");
1536    out.push_str(
1537        "  Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1538    );
1539    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.");
1540    Ok(out.trim_end().to_string())
1541}
1542
1543fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1544    let mut out = String::from("Host inspection: fix_plan\n\n");
1545    out.push_str(&format!("- Requested issue: {}\n", issue));
1546    out.push_str("- Fix-plan type: generic\n");
1547    out.push_str(
1548        "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1549         Structured lanes available:\n\
1550         - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1551         - Port conflict (address already in use, what owns port)\n\
1552         - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1553         - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1554         - Group Policy (gpedit, local policy, administrative template)\n\
1555         - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1556         - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1557         - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1558         - Service config (start/stop/restart/enable/disable a service)\n\
1559         - Windows activation (product key, not activated, kms)\n\
1560         - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1561         - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1562         - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1563         - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1564    );
1565    Ok(out.trim_end().to_string())
1566}
1567
1568fn inspect_resource_load() -> Result<String, String> {
1569    #[cfg(target_os = "windows")]
1570    {
1571        let output = Command::new("powershell")
1572            .args([
1573                "-NoProfile",
1574                "-Command",
1575                "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1576            ])
1577            .output()
1578            .map_err(|e| format!("Failed to run powershell: {e}"))?;
1579
1580        let text = String::from_utf8_lossy(&output.stdout);
1581        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1582
1583        let cpu_load = lines
1584            .next()
1585            .and_then(|l| l.parse::<u32>().ok())
1586            .unwrap_or(0);
1587        let mem_json = lines.collect::<Vec<_>>().join("");
1588        let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1589
1590        let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1591        let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1592        let used_kb = total_kb.saturating_sub(free_kb);
1593        let mem_percent = if total_kb > 0 {
1594            (used_kb * 100) / total_kb
1595        } else {
1596            0
1597        };
1598
1599        let mut out = String::from("Host inspection: resource_load\n\n");
1600        out.push_str("**System Performance Summary:**\n");
1601        out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1602        out.push_str(&format!(
1603            "- Memory Usage: {} / {} ({}%)\n",
1604            human_bytes(used_kb * 1024),
1605            human_bytes(total_kb * 1024),
1606            mem_percent
1607        ));
1608
1609        if cpu_load > 85 {
1610            out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1611        }
1612        if mem_percent > 90 {
1613            out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1614        }
1615
1616        Ok(out)
1617    }
1618    #[cfg(not(target_os = "windows"))]
1619    {
1620        Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1621    }
1622}
1623
1624#[derive(Debug)]
1625enum EndpointProbe {
1626    Reachable(u16),
1627    Unreachable(String),
1628}
1629
1630async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1631    let client = match reqwest::Client::builder()
1632        .timeout(std::time::Duration::from_secs(3))
1633        .build()
1634    {
1635        Ok(client) => client,
1636        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1637    };
1638
1639    match client.get(url).send().await {
1640        Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1641        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1642    }
1643}
1644
1645async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1646    if configured_api.contains("11434") {
1647        let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1648        let url = format!("{}/api/ps", base);
1649        let client = reqwest::Client::builder()
1650            .timeout(std::time::Duration::from_secs(3))
1651            .build()
1652            .ok()?;
1653        let response = client.get(url).send().await.ok()?;
1654        let body = response.json::<serde_json::Value>().await.ok()?;
1655        let entries = body["models"].as_array()?;
1656        for entry in entries {
1657            let name = entry["name"]
1658                .as_str()
1659                .or_else(|| entry["model"].as_str())
1660                .unwrap_or_default();
1661            let lower = name.to_ascii_lowercase();
1662            if lower.contains("embed")
1663                || lower.contains("embedding")
1664                || lower.contains("minilm")
1665                || lower.contains("bge")
1666                || lower.contains("e5")
1667            {
1668                return Some(name.to_string());
1669            }
1670        }
1671        return None;
1672    }
1673
1674    let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1675    let url = format!("{}/api/v0/models", base);
1676    let client = reqwest::Client::builder()
1677        .timeout(std::time::Duration::from_secs(3))
1678        .build()
1679        .ok()?;
1680
1681    #[derive(serde::Deserialize)]
1682    struct ModelList {
1683        data: Vec<ModelEntry>,
1684    }
1685    #[derive(serde::Deserialize)]
1686    struct ModelEntry {
1687        id: String,
1688        #[serde(rename = "type", default)]
1689        model_type: String,
1690        #[serde(default)]
1691        state: String,
1692    }
1693
1694    let response = client.get(url).send().await.ok()?;
1695    let models = response.json::<ModelList>().await.ok()?;
1696    models
1697        .data
1698        .into_iter()
1699        .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1700        .map(|model| model.id)
1701}
1702
1703fn first_port_in_text(text: &str) -> Option<u16> {
1704    text.split(|c: char| !c.is_ascii_digit())
1705        .find(|fragment| !fragment.is_empty())
1706        .and_then(|fragment| fragment.parse::<u16>().ok())
1707}
1708
1709fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1710    let mut processes = collect_processes()?;
1711    if let Some(filter) = name_filter.as_deref() {
1712        let lowered = filter.to_ascii_lowercase();
1713        processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1714    }
1715    processes.sort_by(|a, b| {
1716        let a_cpu = a.cpu_percent.unwrap_or(0.0);
1717        let b_cpu = b.cpu_percent.unwrap_or(0.0);
1718        b_cpu
1719            .partial_cmp(&a_cpu)
1720            .unwrap_or(std::cmp::Ordering::Equal)
1721            .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1722            .then_with(|| a.name.cmp(&b.name))
1723            .then_with(|| a.pid.cmp(&b.pid))
1724    });
1725
1726    let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1727
1728    let mut out = String::from("Host inspection: processes\n\n");
1729    if let Some(filter) = name_filter.as_deref() {
1730        out.push_str(&format!("- Filter name: {}\n", filter));
1731    }
1732    out.push_str(&format!("- Processes found: {}\n", processes.len()));
1733    out.push_str(&format!(
1734        "- Total reported working set: {}\n",
1735        human_bytes(total_memory)
1736    ));
1737
1738    if processes.is_empty() {
1739        out.push_str("\nNo running processes matched.");
1740        return Ok(out);
1741    }
1742
1743    out.push_str("\nTop processes by resource usage:\n");
1744    for entry in processes.iter().take(max_entries) {
1745        let cpu_str = entry
1746            .cpu_percent
1747            .map(|p| format!(" [CPU: {:.1}%]", p))
1748            .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1749            .unwrap_or_default();
1750        let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1751            format!(" [I/O R:{}/W:{}]", r, w)
1752        } else {
1753            " [I/O unknown]".to_string()
1754        };
1755        out.push_str(&format!(
1756            "- {} (pid {}) - {}{}{}{}\n",
1757            entry.name,
1758            entry.pid,
1759            human_bytes(entry.memory_bytes),
1760            cpu_str,
1761            io_str,
1762            entry
1763                .detail
1764                .as_deref()
1765                .map(|detail| format!(" [{}]", detail))
1766                .unwrap_or_default()
1767        ));
1768    }
1769    if processes.len() > max_entries {
1770        out.push_str(&format!(
1771            "- ... {} more processes omitted\n",
1772            processes.len() - max_entries
1773        ));
1774    }
1775
1776    Ok(out.trim_end().to_string())
1777}
1778
1779fn inspect_network(max_entries: usize) -> Result<String, String> {
1780    let adapters = collect_network_adapters()?;
1781    let active_count = adapters
1782        .iter()
1783        .filter(|adapter| adapter.is_active())
1784        .count();
1785    let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1786
1787    let mut out = String::from("Host inspection: network\n\n");
1788    out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1789    out.push_str(&format!("- Active adapters: {}\n", active_count));
1790    out.push_str(&format!(
1791        "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1792        exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1793    ));
1794
1795    if adapters.is_empty() {
1796        out.push_str("\nNo adapter details were detected.");
1797        return Ok(out);
1798    }
1799
1800    out.push_str("\nAdapter summary:\n");
1801    for adapter in adapters.iter().take(max_entries) {
1802        let status = if adapter.is_active() {
1803            "active"
1804        } else if adapter.disconnected {
1805            "disconnected"
1806        } else {
1807            "idle"
1808        };
1809        let mut details = vec![status.to_string()];
1810        if !adapter.ipv4.is_empty() {
1811            details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1812        }
1813        if !adapter.ipv6.is_empty() {
1814            details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1815        }
1816        if !adapter.gateways.is_empty() {
1817            details.push(format!("gateway {}", adapter.gateways.join(", ")));
1818        }
1819        if !adapter.dns_servers.is_empty() {
1820            details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1821        }
1822        out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1823    }
1824    if adapters.len() > max_entries {
1825        out.push_str(&format!(
1826            "- ... {} more adapters omitted\n",
1827            adapters.len() - max_entries
1828        ));
1829    }
1830
1831    Ok(out.trim_end().to_string())
1832}
1833
1834fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1835    let mut out = String::from("Host inspection: lan_discovery\n\n");
1836
1837    #[cfg(target_os = "windows")]
1838    {
1839        let n = max_entries.clamp(5, 20);
1840        let adapters = collect_network_adapters()?;
1841        let services = collect_services().unwrap_or_default();
1842        let active_adapters: Vec<&NetworkAdapter> = adapters
1843            .iter()
1844            .filter(|adapter| adapter.is_active())
1845            .collect();
1846        let gateways: Vec<String> = active_adapters
1847            .iter()
1848            .flat_map(|adapter| adapter.gateways.clone())
1849            .collect::<HashSet<_>>()
1850            .into_iter()
1851            .collect();
1852
1853        let neighbor_script = r#"
1854$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1855    Where-Object {
1856        $_.IPAddress -notlike '127.*' -and
1857        $_.IPAddress -notlike '169.254*' -and
1858        $_.State -notin @('Unreachable','Invalid')
1859    } |
1860    Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1861$neighbors | ConvertTo-Json -Compress
1862"#;
1863        let neighbor_text = Command::new("powershell")
1864            .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1865            .output()
1866            .ok()
1867            .and_then(|o| String::from_utf8(o.stdout).ok())
1868            .unwrap_or_default();
1869        let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1870            .into_iter()
1871            .take(n)
1872            .collect();
1873
1874        let listener_script = r#"
1875Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1876    Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1877    Select-Object LocalAddress, LocalPort, OwningProcess |
1878    ForEach-Object {
1879        $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1880        "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1881    }
1882"#;
1883        let listener_text = Command::new("powershell")
1884            .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1885            .output()
1886            .ok()
1887            .and_then(|o| String::from_utf8(o.stdout).ok())
1888            .unwrap_or_default();
1889        let listeners: Vec<(String, u16, String, String)> = listener_text
1890            .lines()
1891            .filter_map(|line| {
1892                let parts: Vec<&str> = line.trim().split('|').collect();
1893                if parts.len() < 4 {
1894                    return None;
1895                }
1896                Some((
1897                    parts[0].to_string(),
1898                    parts[1].parse::<u16>().ok()?,
1899                    parts[2].to_string(),
1900                    parts[3].to_string(),
1901                ))
1902            })
1903            .take(n)
1904            .collect();
1905
1906        let smb_mapping_script = r#"
1907Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1908    ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1909"#;
1910        let smb_mappings: Vec<String> = Command::new("powershell")
1911            .args([
1912                "-NoProfile",
1913                "-NonInteractive",
1914                "-Command",
1915                smb_mapping_script,
1916            ])
1917            .output()
1918            .ok()
1919            .and_then(|o| String::from_utf8(o.stdout).ok())
1920            .unwrap_or_default()
1921            .lines()
1922            .take(n)
1923            .map(|line| line.trim().to_string())
1924            .filter(|line| !line.is_empty())
1925            .collect();
1926
1927        let smb_connections_script = r#"
1928Get-SmbConnection -ErrorAction SilentlyContinue |
1929    Select-Object ServerName, ShareName, NumOpens |
1930    ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1931"#;
1932        let smb_connections: Vec<String> = Command::new("powershell")
1933            .args([
1934                "-NoProfile",
1935                "-NonInteractive",
1936                "-Command",
1937                smb_connections_script,
1938            ])
1939            .output()
1940            .ok()
1941            .and_then(|o| String::from_utf8(o.stdout).ok())
1942            .unwrap_or_default()
1943            .lines()
1944            .take(n)
1945            .map(|line| line.trim().to_string())
1946            .filter(|line| !line.is_empty())
1947            .collect();
1948
1949        let discovery_service_names = [
1950            "FDResPub",
1951            "fdPHost",
1952            "SSDPSRV",
1953            "upnphost",
1954            "LanmanServer",
1955            "LanmanWorkstation",
1956            "lmhosts",
1957        ];
1958        let discovery_services: Vec<&ServiceEntry> = services
1959            .iter()
1960            .filter(|entry| {
1961                discovery_service_names
1962                    .iter()
1963                    .any(|name| entry.name.eq_ignore_ascii_case(name))
1964            })
1965            .collect();
1966
1967        let mut findings = Vec::new();
1968        if active_adapters.is_empty() {
1969            findings.push(AuditFinding {
1970                finding: "No active LAN adapters were detected.".to_string(),
1971                impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
1972                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(),
1973            });
1974        }
1975
1976        let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
1977            .iter()
1978            .copied()
1979            .filter(|entry| {
1980                !entry.status.eq_ignore_ascii_case("running")
1981                    && !entry.status.eq_ignore_ascii_case("active")
1982            })
1983            .collect();
1984        if !stopped_discovery_services.is_empty() {
1985            let names = stopped_discovery_services
1986                .iter()
1987                .map(|entry| entry.name.as_str())
1988                .collect::<Vec<_>>()
1989                .join(", ");
1990            findings.push(AuditFinding {
1991                finding: format!("Discovery-related services are not running: {names}"),
1992                impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
1993                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(),
1994            });
1995        }
1996
1997        if listeners.is_empty() {
1998            findings.push(AuditFinding {
1999                finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
2000                impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
2001                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(),
2002            });
2003        }
2004
2005        if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
2006            findings.push(AuditFinding {
2007                finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
2008                impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
2009                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(),
2010            });
2011        }
2012
2013        out.push_str("=== Findings ===\n");
2014        if findings.is_empty() {
2015            out.push_str(
2016                "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
2017            );
2018            out.push_str("  Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
2019            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");
2020        } else {
2021            for finding in &findings {
2022                out.push_str(&format!("- Finding: {}\n", finding.finding));
2023                out.push_str(&format!("  Impact: {}\n", finding.impact));
2024                out.push_str(&format!("  Fix: {}\n", finding.fix));
2025            }
2026        }
2027
2028        out.push_str("\n=== Active adapter and gateway summary ===\n");
2029        if active_adapters.is_empty() {
2030            out.push_str("- No active adapters detected.\n");
2031        } else {
2032            for adapter in active_adapters.iter().take(n) {
2033                let ipv4 = if adapter.ipv4.is_empty() {
2034                    "no IPv4".to_string()
2035                } else {
2036                    adapter.ipv4.join(", ")
2037                };
2038                let gateway = if adapter.gateways.is_empty() {
2039                    "no gateway".to_string()
2040                } else {
2041                    adapter.gateways.join(", ")
2042                };
2043                out.push_str(&format!(
2044                    "- {} | IPv4: {} | Gateway: {}\n",
2045                    adapter.name, ipv4, gateway
2046                ));
2047            }
2048        }
2049
2050        out.push_str("\n=== Neighborhood evidence ===\n");
2051        out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
2052        out.push_str(&format!(
2053            "- Neighbor entries observed: {}\n",
2054            neighbors.len()
2055        ));
2056        if neighbors.is_empty() {
2057            out.push_str("- No ARP/neighbor evidence retrieved.\n");
2058        } else {
2059            for (ip, mac, state, iface) in neighbors.iter().take(n) {
2060                out.push_str(&format!(
2061                    "- {} on {} | MAC: {} | State: {}\n",
2062                    ip, iface, mac, state
2063                ));
2064            }
2065        }
2066
2067        out.push_str("\n=== Discovery services ===\n");
2068        if discovery_services.is_empty() {
2069            out.push_str("- Discovery service status unavailable.\n");
2070        } else {
2071            for entry in discovery_services.iter().take(n) {
2072                let startup = entry.startup.as_deref().unwrap_or("unknown");
2073                out.push_str(&format!(
2074                    "- {} | Status: {} | Startup: {}\n",
2075                    entry.name, entry.status, startup
2076                ));
2077            }
2078        }
2079
2080        out.push_str("\n=== Discovery listener surface ===\n");
2081        if listeners.is_empty() {
2082            out.push_str("- No discovery-oriented UDP listeners detected.\n");
2083        } else {
2084            for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2085                let label = match *port {
2086                    137 => "NetBIOS Name Service",
2087                    138 => "NetBIOS Datagram",
2088                    1900 => "SSDP/UPnP",
2089                    5353 => "mDNS",
2090                    5355 => "LLMNR",
2091                    _ => "Discovery",
2092                };
2093                let proc_label = if proc_name.is_empty() {
2094                    "unknown".to_string()
2095                } else {
2096                    proc_name.clone()
2097                };
2098                out.push_str(&format!(
2099                    "- {}:{} | {} | PID {} ({})\n",
2100                    addr, port, label, pid, proc_label
2101                ));
2102            }
2103        }
2104
2105        out.push_str("\n=== SMB and neighborhood visibility ===\n");
2106        if smb_mappings.is_empty() && smb_connections.is_empty() {
2107            out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2108        } else {
2109            if !smb_mappings.is_empty() {
2110                out.push_str("- Mapped drives:\n");
2111                for mapping in smb_mappings.iter().take(n) {
2112                    let parts: Vec<&str> = mapping.split('|').collect();
2113                    if parts.len() >= 2 {
2114                        out.push_str(&format!("  - {} -> {}\n", parts[0], parts[1]));
2115                    }
2116                }
2117            }
2118            if !smb_connections.is_empty() {
2119                out.push_str("- Active SMB connections:\n");
2120                for connection in smb_connections.iter().take(n) {
2121                    let parts: Vec<&str> = connection.split('|').collect();
2122                    if parts.len() >= 3 {
2123                        out.push_str(&format!(
2124                            "  - {}\\{} | Opens: {}\n",
2125                            parts[0], parts[1], parts[2]
2126                        ));
2127                    }
2128                }
2129            }
2130        }
2131    }
2132
2133    #[cfg(not(target_os = "windows"))]
2134    {
2135        let n = max_entries.clamp(5, 20);
2136        let adapters = collect_network_adapters()?;
2137        let arp_output = Command::new("ip")
2138            .args(["neigh"])
2139            .output()
2140            .ok()
2141            .and_then(|o| String::from_utf8(o.stdout).ok())
2142            .unwrap_or_default();
2143        let neighbors: Vec<&str> = arp_output
2144            .lines()
2145            .filter(|line| !line.trim().is_empty())
2146            .take(n)
2147            .collect();
2148
2149        out.push_str("=== Findings ===\n");
2150        if adapters.iter().any(|adapter| adapter.is_active()) {
2151            out.push_str(
2152                "- Finding: LAN discovery support is partially available on this platform.\n",
2153            );
2154            out.push_str("  Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2155            out.push_str("  Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2156        } else {
2157            out.push_str("- Finding: No active LAN adapters were detected.\n");
2158            out.push_str(
2159                "  Impact: Neighborhood discovery cannot work without an active interface.\n",
2160            );
2161            out.push_str("  Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2162        }
2163
2164        out.push_str("\n=== Active adapter and gateway summary ===\n");
2165        if adapters.is_empty() {
2166            out.push_str("- No adapters detected.\n");
2167        } else {
2168            for adapter in adapters.iter().take(n) {
2169                let ipv4 = if adapter.ipv4.is_empty() {
2170                    "no IPv4".to_string()
2171                } else {
2172                    adapter.ipv4.join(", ")
2173                };
2174                let gateway = if adapter.gateways.is_empty() {
2175                    "no gateway".to_string()
2176                } else {
2177                    adapter.gateways.join(", ")
2178                };
2179                out.push_str(&format!(
2180                    "- {} | IPv4: {} | Gateway: {}\n",
2181                    adapter.name, ipv4, gateway
2182                ));
2183            }
2184        }
2185
2186        out.push_str("\n=== Neighborhood evidence ===\n");
2187        if neighbors.is_empty() {
2188            out.push_str("- No neighbor entries detected.\n");
2189        } else {
2190            for line in neighbors {
2191                out.push_str(&format!("- {}\n", line.trim()));
2192            }
2193        }
2194    }
2195
2196    Ok(out.trim_end().to_string())
2197}
2198
2199fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2200    let mut services = collect_services()?;
2201    if let Some(filter) = name_filter.as_deref() {
2202        let lowered = filter.to_ascii_lowercase();
2203        services.retain(|entry| {
2204            entry.name.to_ascii_lowercase().contains(&lowered)
2205                || entry
2206                    .display_name
2207                    .as_deref()
2208                    .map(|d| d.to_ascii_lowercase().contains(&lowered))
2209                    .unwrap_or(false)
2210        });
2211    }
2212
2213    services.sort_by(|a, b| {
2214        let a_running =
2215            a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2216        let b_running =
2217            b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2218        b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2219    });
2220
2221    let running = services
2222        .iter()
2223        .filter(|entry| {
2224            entry.status.eq_ignore_ascii_case("running")
2225                || entry.status.eq_ignore_ascii_case("active")
2226        })
2227        .count();
2228    let failed = services
2229        .iter()
2230        .filter(|entry| {
2231            entry.status.eq_ignore_ascii_case("failed")
2232                || entry.status.eq_ignore_ascii_case("error")
2233                || entry.status.eq_ignore_ascii_case("stopped")
2234        })
2235        .count();
2236
2237    let mut out = String::from("Host inspection: services\n\n");
2238    if let Some(filter) = name_filter.as_deref() {
2239        out.push_str(&format!("- Filter name: {}\n", filter));
2240    }
2241    out.push_str(&format!("- Services found: {}\n", services.len()));
2242    out.push_str(&format!("- Running/active: {}\n", running));
2243    out.push_str(&format!("- Failed/stopped: {}\n", failed));
2244
2245    if services.is_empty() {
2246        out.push_str("\nNo services matched.");
2247        return Ok(out);
2248    }
2249
2250    // Split into running and stopped sections so both are always visible.
2251    let per_section = (max_entries / 2).max(5);
2252
2253    let running_services: Vec<_> = services
2254        .iter()
2255        .filter(|e| {
2256            e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2257        })
2258        .collect();
2259    let stopped_services: Vec<_> = services
2260        .iter()
2261        .filter(|e| {
2262            e.status.eq_ignore_ascii_case("stopped")
2263                || e.status.eq_ignore_ascii_case("failed")
2264                || e.status.eq_ignore_ascii_case("error")
2265        })
2266        .collect();
2267
2268    let fmt_entry = |entry: &&ServiceEntry| {
2269        let startup = entry
2270            .startup
2271            .as_deref()
2272            .map(|v| format!(" | startup {}", v))
2273            .unwrap_or_default();
2274        let logon = entry
2275            .start_name
2276            .as_deref()
2277            .map(|v| format!(" | LogOn: {}", v))
2278            .unwrap_or_default();
2279        let display = entry
2280            .display_name
2281            .as_deref()
2282            .filter(|v| *v != &entry.name)
2283            .map(|v| format!(" [{}]", v))
2284            .unwrap_or_default();
2285        format!(
2286            "- {}{} - {}{}{}\n",
2287            entry.name, display, entry.status, startup, logon
2288        )
2289    };
2290
2291    out.push_str(&format!(
2292        "\nRunning services ({} total, showing up to {}):\n",
2293        running_services.len(),
2294        per_section
2295    ));
2296    for entry in running_services.iter().take(per_section) {
2297        out.push_str(&fmt_entry(entry));
2298    }
2299    if running_services.len() > per_section {
2300        out.push_str(&format!(
2301            "- ... {} more running services omitted\n",
2302            running_services.len() - per_section
2303        ));
2304    }
2305
2306    out.push_str(&format!(
2307        "\nStopped/failed services ({} total, showing up to {}):\n",
2308        stopped_services.len(),
2309        per_section
2310    ));
2311    for entry in stopped_services.iter().take(per_section) {
2312        out.push_str(&fmt_entry(entry));
2313    }
2314    if stopped_services.len() > per_section {
2315        out.push_str(&format!(
2316            "- ... {} more stopped services omitted\n",
2317            stopped_services.len() - per_section
2318        ));
2319    }
2320
2321    Ok(out.trim_end().to_string())
2322}
2323
2324async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2325    inspect_directory("Disk", path, max_entries).await
2326}
2327
2328fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2329    let mut listeners = collect_listening_ports()?;
2330    if let Some(port) = port_filter {
2331        listeners.retain(|entry| entry.port == port);
2332    }
2333    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2334
2335    let mut out = String::from("Host inspection: ports\n\n");
2336    if let Some(port) = port_filter {
2337        out.push_str(&format!("- Filter port: {}\n", port));
2338    }
2339    out.push_str(&format!(
2340        "- Listening endpoints found: {}\n",
2341        listeners.len()
2342    ));
2343
2344    if listeners.is_empty() {
2345        out.push_str("\nNo listening endpoints matched.");
2346        return Ok(out);
2347    }
2348
2349    out.push_str("\nListening endpoints:\n");
2350    for entry in listeners.iter().take(max_entries) {
2351        let pid_str = entry
2352            .pid
2353            .as_deref()
2354            .map(|p| format!(" pid {}", p))
2355            .unwrap_or_default();
2356        let name_str = entry
2357            .process_name
2358            .as_deref()
2359            .map(|n| format!(" [{}]", n))
2360            .unwrap_or_default();
2361        out.push_str(&format!(
2362            "- {} {} ({}){}{}\n",
2363            entry.protocol, entry.local, entry.state, pid_str, name_str
2364        ));
2365    }
2366    if listeners.len() > max_entries {
2367        out.push_str(&format!(
2368            "- ... {} more listening endpoints omitted\n",
2369            listeners.len() - max_entries
2370        ));
2371    }
2372
2373    Ok(out.trim_end().to_string())
2374}
2375
2376fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2377    if !path.exists() {
2378        return Err(format!("Path does not exist: {}", path.display()));
2379    }
2380    if !path.is_dir() {
2381        return Err(format!("Path is not a directory: {}", path.display()));
2382    }
2383
2384    let markers = collect_project_markers(&path);
2385    let hematite_state = collect_hematite_state(&path);
2386    let git_state = inspect_git_state(&path);
2387    let release_state = inspect_release_artifacts(&path);
2388
2389    let mut out = String::from("Host inspection: repo_doctor\n\n");
2390    out.push_str(&format!("- Path: {}\n", path.display()));
2391    out.push_str(&format!(
2392        "- Workspace mode: {}\n",
2393        workspace_mode_for_path(&path)
2394    ));
2395
2396    if markers.is_empty() {
2397        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");
2398    } else {
2399        out.push_str("- Project markers:\n");
2400        for marker in markers.iter().take(max_entries) {
2401            out.push_str(&format!("  - {}\n", marker));
2402        }
2403    }
2404
2405    match git_state {
2406        Some(git) => {
2407            out.push_str(&format!("- Git root: {}\n", git.root.display()));
2408            out.push_str(&format!("- Git branch: {}\n", git.branch));
2409            out.push_str(&format!("- Git status: {}\n", git.status_label()));
2410        }
2411        None => out.push_str("- Git: not inside a detected work tree\n"),
2412    }
2413
2414    out.push_str(&format!(
2415        "- Hematite docs/imports/reports: {}/{}/{}\n",
2416        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2417    ));
2418    if hematite_state.workspace_profile {
2419        out.push_str("- Workspace profile: present\n");
2420    } else {
2421        out.push_str("- Workspace profile: absent\n");
2422    }
2423
2424    if let Some(release) = release_state {
2425        out.push_str(&format!("- Cargo version: {}\n", release.version));
2426        out.push_str(&format!(
2427            "- Windows artifacts for current version: {}/{}/{}\n",
2428            bool_label(release.portable_dir),
2429            bool_label(release.portable_zip),
2430            bool_label(release.setup_exe)
2431        ));
2432    }
2433
2434    Ok(out.trim_end().to_string())
2435}
2436
2437async fn inspect_known_directory(
2438    label: &str,
2439    path: Option<PathBuf>,
2440    max_entries: usize,
2441) -> Result<String, String> {
2442    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2443    inspect_directory(label, path, max_entries).await
2444}
2445
2446async fn inspect_directory(
2447    label: &str,
2448    path: PathBuf,
2449    max_entries: usize,
2450) -> Result<String, String> {
2451    let label = label.to_string();
2452    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2453        .await
2454        .map_err(|e| format!("inspect_host task failed: {e}"))?
2455}
2456
2457fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2458    if !path.exists() {
2459        return Err(format!("Path does not exist: {}", path.display()));
2460    }
2461    if !path.is_dir() {
2462        return Err(format!("Path is not a directory: {}", path.display()));
2463    }
2464
2465    let mut top_level_entries = Vec::new();
2466    for entry in fs::read_dir(path)
2467        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2468    {
2469        match entry {
2470            Ok(entry) => top_level_entries.push(entry),
2471            Err(_) => continue,
2472        }
2473    }
2474    top_level_entries.sort_by_key(|entry| entry.file_name());
2475
2476    let top_level_count = top_level_entries.len();
2477    let mut sample_names = Vec::new();
2478    let mut largest_entries = Vec::new();
2479    let mut aggregate = PathAggregate::default();
2480    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2481
2482    for entry in top_level_entries {
2483        let name = entry.file_name().to_string_lossy().to_string();
2484        if sample_names.len() < max_entries {
2485            sample_names.push(name.clone());
2486        }
2487        let kind = match entry.file_type() {
2488            Ok(ft) if ft.is_dir() => "dir",
2489            Ok(ft) if ft.is_symlink() => "symlink",
2490            _ => "file",
2491        };
2492        let stats = measure_path(&entry.path(), &mut budget);
2493        aggregate.merge(&stats);
2494        largest_entries.push(LargestEntry {
2495            name,
2496            kind,
2497            bytes: stats.total_bytes,
2498        });
2499    }
2500
2501    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2502
2503    let mut out = format!("Directory inspection: {}\n\n", label);
2504    out.push_str(&format!("- Path: {}\n", path.display()));
2505    out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2506    out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2507    out.push_str(&format!(
2508        "- Recursive directories: {}\n",
2509        aggregate.dir_count
2510    ));
2511    out.push_str(&format!(
2512        "- Total size: {}{}\n",
2513        human_bytes(aggregate.total_bytes),
2514        if aggregate.partial {
2515            " (partial scan)"
2516        } else {
2517            ""
2518        }
2519    ));
2520    if aggregate.skipped_entries > 0 {
2521        out.push_str(&format!(
2522            "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2523            aggregate.skipped_entries
2524        ));
2525    }
2526
2527    if !largest_entries.is_empty() {
2528        out.push_str("\nLargest top-level entries:\n");
2529        for entry in largest_entries.iter().take(max_entries) {
2530            out.push_str(&format!(
2531                "- {} [{}] - {}\n",
2532                entry.name,
2533                entry.kind,
2534                human_bytes(entry.bytes)
2535            ));
2536        }
2537    }
2538
2539    if !sample_names.is_empty() {
2540        out.push_str("\nSample names:\n");
2541        for name in sample_names {
2542            out.push_str(&format!("- {}\n", name));
2543        }
2544    }
2545
2546    Ok(out.trim_end().to_string())
2547}
2548
2549fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2550    let trimmed = raw.trim();
2551    if trimmed.is_empty() {
2552        return Err("Path must not be empty.".to_string());
2553    }
2554
2555    if let Some(rest) = trimmed
2556        .strip_prefix("~/")
2557        .or_else(|| trimmed.strip_prefix("~\\"))
2558    {
2559        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2560        return Ok(home.join(rest));
2561    }
2562
2563    let path = PathBuf::from(trimmed);
2564    if path.is_absolute() {
2565        Ok(path)
2566    } else {
2567        let cwd =
2568            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2569        let full_path = cwd.join(&path);
2570
2571        // Heuristic: If it's a relative path to .hematite or hematite.exe and doesn't exist here,
2572        // check the user's home directory.
2573        if !full_path.exists()
2574            && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2575        {
2576            if let Some(home) = home::home_dir() {
2577                let home_path = home.join(trimmed);
2578                if home_path.exists() {
2579                    return Ok(home_path);
2580                }
2581            }
2582        }
2583
2584        Ok(full_path)
2585    }
2586}
2587
2588fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2589    workspace_mode_for_path(workspace_root)
2590}
2591
2592fn workspace_mode_for_path(path: &Path) -> &'static str {
2593    if is_project_marker_path(path) {
2594        "project"
2595    } else if path.join(".hematite").join("docs").exists()
2596        || path.join(".hematite").join("imports").exists()
2597        || path.join(".hematite").join("reports").exists()
2598    {
2599        "docs-only"
2600    } else {
2601        "general directory"
2602    }
2603}
2604
2605fn is_project_marker_path(path: &Path) -> bool {
2606    [
2607        "Cargo.toml",
2608        "package.json",
2609        "pyproject.toml",
2610        "go.mod",
2611        "composer.json",
2612        "requirements.txt",
2613        "Makefile",
2614        "justfile",
2615    ]
2616    .iter()
2617    .any(|name| path.join(name).exists())
2618        || path.join(".git").exists()
2619}
2620
2621fn preferred_shell_label() -> &'static str {
2622    #[cfg(target_os = "windows")]
2623    {
2624        "PowerShell"
2625    }
2626    #[cfg(not(target_os = "windows"))]
2627    {
2628        "sh"
2629    }
2630}
2631
2632fn desktop_dir() -> Option<PathBuf> {
2633    home::home_dir().map(|home| home.join("Desktop"))
2634}
2635
2636fn downloads_dir() -> Option<PathBuf> {
2637    home::home_dir().map(|home| home.join("Downloads"))
2638}
2639
2640fn count_top_level_items(path: &Path) -> Result<usize, String> {
2641    let mut count = 0usize;
2642    for entry in
2643        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2644    {
2645        if entry.is_ok() {
2646            count += 1;
2647        }
2648    }
2649    Ok(count)
2650}
2651
2652#[derive(Default)]
2653struct PathAggregate {
2654    total_bytes: u64,
2655    file_count: u64,
2656    dir_count: u64,
2657    skipped_entries: u64,
2658    partial: bool,
2659}
2660
2661impl PathAggregate {
2662    fn merge(&mut self, other: &PathAggregate) {
2663        self.total_bytes += other.total_bytes;
2664        self.file_count += other.file_count;
2665        self.dir_count += other.dir_count;
2666        self.skipped_entries += other.skipped_entries;
2667        self.partial |= other.partial;
2668    }
2669}
2670
2671struct LargestEntry {
2672    name: String,
2673    kind: &'static str,
2674    bytes: u64,
2675}
2676
2677fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2678    if *budget == 0 {
2679        return PathAggregate {
2680            partial: true,
2681            skipped_entries: 1,
2682            ..PathAggregate::default()
2683        };
2684    }
2685    *budget -= 1;
2686
2687    let metadata = match fs::symlink_metadata(path) {
2688        Ok(metadata) => metadata,
2689        Err(_) => {
2690            return PathAggregate {
2691                skipped_entries: 1,
2692                ..PathAggregate::default()
2693            }
2694        }
2695    };
2696
2697    let file_type = metadata.file_type();
2698    if file_type.is_symlink() {
2699        return PathAggregate {
2700            skipped_entries: 1,
2701            ..PathAggregate::default()
2702        };
2703    }
2704
2705    if metadata.is_file() {
2706        return PathAggregate {
2707            total_bytes: metadata.len(),
2708            file_count: 1,
2709            ..PathAggregate::default()
2710        };
2711    }
2712
2713    if !metadata.is_dir() {
2714        return PathAggregate::default();
2715    }
2716
2717    let mut aggregate = PathAggregate {
2718        dir_count: 1,
2719        ..PathAggregate::default()
2720    };
2721
2722    let read_dir = match fs::read_dir(path) {
2723        Ok(read_dir) => read_dir,
2724        Err(_) => {
2725            aggregate.skipped_entries += 1;
2726            return aggregate;
2727        }
2728    };
2729
2730    for child in read_dir {
2731        match child {
2732            Ok(child) => {
2733                let child_stats = measure_path(&child.path(), budget);
2734                aggregate.merge(&child_stats);
2735            }
2736            Err(_) => aggregate.skipped_entries += 1,
2737        }
2738    }
2739
2740    aggregate
2741}
2742
2743struct PathAnalysis {
2744    total_entries: usize,
2745    unique_entries: usize,
2746    entries: Vec<String>,
2747    duplicate_entries: Vec<String>,
2748    missing_entries: Vec<String>,
2749}
2750
2751fn analyze_path_env() -> PathAnalysis {
2752    let mut entries = Vec::new();
2753    let mut duplicate_entries = Vec::new();
2754    let mut missing_entries = Vec::new();
2755    let mut seen = HashSet::new();
2756
2757    let raw_path = std::env::var_os("PATH").unwrap_or_default();
2758    for path in std::env::split_paths(&raw_path) {
2759        let display = path.display().to_string();
2760        if display.trim().is_empty() {
2761            continue;
2762        }
2763
2764        let normalized = normalize_path_entry(&display);
2765        if !seen.insert(normalized) {
2766            duplicate_entries.push(display.clone());
2767        }
2768        if !path.exists() {
2769            missing_entries.push(display.clone());
2770        }
2771        entries.push(display);
2772    }
2773
2774    let total_entries = entries.len();
2775    let unique_entries = seen.len();
2776
2777    PathAnalysis {
2778        total_entries,
2779        unique_entries,
2780        entries,
2781        duplicate_entries,
2782        missing_entries,
2783    }
2784}
2785
2786fn normalize_path_entry(value: &str) -> String {
2787    #[cfg(target_os = "windows")]
2788    {
2789        value
2790            .replace('/', "\\")
2791            .trim_end_matches(['\\', '/'])
2792            .to_ascii_lowercase()
2793    }
2794    #[cfg(not(target_os = "windows"))]
2795    {
2796        value.trim_end_matches('/').to_string()
2797    }
2798}
2799
2800struct ToolchainReport {
2801    found: Vec<(String, String)>,
2802    missing: Vec<String>,
2803}
2804
2805struct PackageManagerReport {
2806    found: Vec<(String, String)>,
2807}
2808
2809#[derive(Debug, Clone)]
2810struct ProcessEntry {
2811    name: String,
2812    pid: u32,
2813    memory_bytes: u64,
2814    cpu_seconds: Option<f64>,
2815    cpu_percent: Option<f64>,
2816    read_ops: Option<u64>,
2817    write_ops: Option<u64>,
2818    detail: Option<String>,
2819}
2820
2821#[derive(Debug, Clone)]
2822struct ServiceEntry {
2823    name: String,
2824    status: String,
2825    startup: Option<String>,
2826    display_name: Option<String>,
2827    start_name: Option<String>,
2828}
2829
2830#[derive(Debug, Clone, Default)]
2831struct NetworkAdapter {
2832    name: String,
2833    ipv4: Vec<String>,
2834    ipv6: Vec<String>,
2835    gateways: Vec<String>,
2836    dns_servers: Vec<String>,
2837    disconnected: bool,
2838}
2839
2840impl NetworkAdapter {
2841    fn is_active(&self) -> bool {
2842        !self.disconnected
2843            && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2844    }
2845}
2846
2847#[derive(Debug, Clone, Copy, Default)]
2848struct ListenerExposureSummary {
2849    loopback_only: usize,
2850    wildcard_public: usize,
2851    specific_bind: usize,
2852}
2853
2854#[derive(Debug, Clone)]
2855struct ListeningPort {
2856    protocol: String,
2857    local: String,
2858    port: u16,
2859    state: String,
2860    pid: Option<String>,
2861    process_name: Option<String>,
2862}
2863
2864fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2865    #[cfg(target_os = "windows")]
2866    {
2867        collect_windows_listening_ports()
2868    }
2869    #[cfg(not(target_os = "windows"))]
2870    {
2871        collect_unix_listening_ports()
2872    }
2873}
2874
2875fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2876    #[cfg(target_os = "windows")]
2877    {
2878        collect_windows_network_adapters()
2879    }
2880    #[cfg(not(target_os = "windows"))]
2881    {
2882        collect_unix_network_adapters()
2883    }
2884}
2885
2886fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2887    #[cfg(target_os = "windows")]
2888    {
2889        collect_windows_services()
2890    }
2891    #[cfg(not(target_os = "windows"))]
2892    {
2893        collect_unix_services()
2894    }
2895}
2896
2897#[cfg(target_os = "windows")]
2898fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2899    let output = Command::new("netstat")
2900        .args(["-ano", "-p", "tcp"])
2901        .output()
2902        .map_err(|e| format!("Failed to run netstat: {e}"))?;
2903    if !output.status.success() {
2904        return Err("netstat returned a non-success status.".to_string());
2905    }
2906
2907    let text = String::from_utf8_lossy(&output.stdout);
2908    let mut listeners = Vec::new();
2909    for line in text.lines() {
2910        let trimmed = line.trim();
2911        if !trimmed.starts_with("TCP") {
2912            continue;
2913        }
2914        let cols: Vec<&str> = trimmed.split_whitespace().collect();
2915        if cols.len() < 5 || cols[3] != "LISTENING" {
2916            continue;
2917        }
2918        let Some(port) = extract_port_from_socket(cols[1]) else {
2919            continue;
2920        };
2921        listeners.push(ListeningPort {
2922            protocol: cols[0].to_string(),
2923            local: cols[1].to_string(),
2924            port,
2925            state: cols[3].to_string(),
2926            pid: Some(cols[4].to_string()),
2927            process_name: None,
2928        });
2929    }
2930
2931    // Enrich with process names via PowerShell — works without elevation for
2932    // most user-space processes. System processes (PID 4, etc.) stay unnamed.
2933    let unique_pids: Vec<String> = listeners
2934        .iter()
2935        .filter_map(|l| l.pid.clone())
2936        .collect::<HashSet<_>>()
2937        .into_iter()
2938        .collect();
2939
2940    if !unique_pids.is_empty() {
2941        let pid_list = unique_pids.join(",");
2942        let ps_cmd = format!(
2943            "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2944            pid_list
2945        );
2946        if let Ok(ps_out) = Command::new("powershell")
2947            .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2948            .output()
2949        {
2950            let mut pid_map = std::collections::HashMap::<String, String>::new();
2951            let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2952            for line in ps_text.lines() {
2953                let parts: Vec<&str> = line.split_whitespace().collect();
2954                if parts.len() >= 2 {
2955                    pid_map.insert(parts[0].to_string(), parts[1].to_string());
2956                }
2957            }
2958            for listener in &mut listeners {
2959                if let Some(pid) = &listener.pid {
2960                    listener.process_name = pid_map.get(pid).cloned();
2961                }
2962            }
2963        }
2964    }
2965
2966    Ok(listeners)
2967}
2968
2969#[cfg(not(target_os = "windows"))]
2970fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2971    let output = Command::new("ss")
2972        .args(["-ltn"])
2973        .output()
2974        .map_err(|e| format!("Failed to run ss: {e}"))?;
2975    if !output.status.success() {
2976        return Err("ss returned a non-success status.".to_string());
2977    }
2978
2979    let text = String::from_utf8_lossy(&output.stdout);
2980    let mut listeners = Vec::new();
2981    for line in text.lines().skip(1) {
2982        let cols: Vec<&str> = line.split_whitespace().collect();
2983        if cols.len() < 4 {
2984            continue;
2985        }
2986        let Some(port) = extract_port_from_socket(cols[3]) else {
2987            continue;
2988        };
2989        listeners.push(ListeningPort {
2990            protocol: "tcp".to_string(),
2991            local: cols[3].to_string(),
2992            port,
2993            state: cols[0].to_string(),
2994            pid: None,
2995            process_name: None,
2996        });
2997    }
2998
2999    Ok(listeners)
3000}
3001
3002fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
3003    #[cfg(target_os = "windows")]
3004    {
3005        collect_windows_processes()
3006    }
3007    #[cfg(not(target_os = "windows"))]
3008    {
3009        collect_unix_processes()
3010    }
3011}
3012
3013#[cfg(target_os = "windows")]
3014fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
3015    let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
3016    let output = Command::new("powershell")
3017        .args(["-NoProfile", "-Command", command])
3018        .output()
3019        .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
3020    if !output.status.success() {
3021        return Err("PowerShell service inspection returned a non-success status.".to_string());
3022    }
3023
3024    parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
3025}
3026
3027#[cfg(not(target_os = "windows"))]
3028fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
3029    let status_output = Command::new("systemctl")
3030        .args([
3031            "list-units",
3032            "--type=service",
3033            "--all",
3034            "--no-pager",
3035            "--no-legend",
3036            "--plain",
3037        ])
3038        .output()
3039        .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3040    if !status_output.status.success() {
3041        return Err("systemctl list-units returned a non-success status.".to_string());
3042    }
3043
3044    let startup_output = Command::new("systemctl")
3045        .args([
3046            "list-unit-files",
3047            "--type=service",
3048            "--no-legend",
3049            "--no-pager",
3050            "--plain",
3051        ])
3052        .output()
3053        .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3054    if !startup_output.status.success() {
3055        return Err("systemctl list-unit-files returned a non-success status.".to_string());
3056    }
3057
3058    Ok(parse_unix_services(
3059        &String::from_utf8_lossy(&status_output.stdout),
3060        &String::from_utf8_lossy(&startup_output.stdout),
3061    ))
3062}
3063
3064#[cfg(target_os = "windows")]
3065fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3066    let output = Command::new("ipconfig")
3067        .args(["/all"])
3068        .output()
3069        .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3070    if !output.status.success() {
3071        return Err("ipconfig returned a non-success status.".to_string());
3072    }
3073
3074    Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3075        &output.stdout,
3076    )))
3077}
3078
3079#[cfg(not(target_os = "windows"))]
3080fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3081    let addr_output = Command::new("ip")
3082        .args(["-o", "addr", "show", "up"])
3083        .output()
3084        .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3085    if !addr_output.status.success() {
3086        return Err("ip addr returned a non-success status.".to_string());
3087    }
3088
3089    let route_output = Command::new("ip")
3090        .args(["route", "show", "default"])
3091        .output()
3092        .map_err(|e| format!("Failed to run ip route: {e}"))?;
3093    if !route_output.status.success() {
3094        return Err("ip route returned a non-success status.".to_string());
3095    }
3096
3097    let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3098    apply_unix_default_routes(
3099        &mut adapters,
3100        &String::from_utf8_lossy(&route_output.stdout),
3101    );
3102    apply_unix_dns_servers(&mut adapters);
3103    Ok(adapters)
3104}
3105
3106#[cfg(target_os = "windows")]
3107fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3108    // We take two samples of CPU time separated by a short interval to calculate recent CPU %
3109    let script = r#"
3110        $s1 = Get-Process | Select-Object Id, CPU
3111        Start-Sleep -Milliseconds 250
3112        $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3113        $s2 | ForEach-Object {
3114            $p2 = $_
3115            $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3116            $pct = 0.0
3117            if ($p1 -and $p2.CPU -gt $p1.CPU) {
3118                # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3119                # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3120                # Standard Task Manager style is (delta / interval) * 100.
3121                $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3122            }
3123            "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3124        }
3125    "#;
3126
3127    let output = Command::new("powershell")
3128        .args(["-NoProfile", "-Command", script])
3129        .output()
3130        .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3131
3132    let text = String::from_utf8_lossy(&output.stdout);
3133    let mut out = Vec::new();
3134    for line in text.lines() {
3135        let parts: Vec<&str> = line.trim().split('|').collect();
3136        if parts.len() < 5 {
3137            continue;
3138        }
3139        let mut entry = ProcessEntry {
3140            name: "unknown".to_string(),
3141            pid: 0,
3142            memory_bytes: 0,
3143            cpu_seconds: None,
3144            cpu_percent: None,
3145            read_ops: None,
3146            write_ops: None,
3147            detail: None,
3148        };
3149        for p in parts {
3150            if let Some((k, v)) = p.split_once(':') {
3151                match k {
3152                    "PID" => entry.pid = v.parse().unwrap_or(0),
3153                    "NAME" => entry.name = v.to_string(),
3154                    "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3155                    "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3156                    "CPU_P" => entry.cpu_percent = v.parse().ok(),
3157                    "READ" => entry.read_ops = v.parse().ok(),
3158                    "WRITE" => entry.write_ops = v.parse().ok(),
3159                    _ => {}
3160                }
3161            }
3162        }
3163        out.push(entry);
3164    }
3165    Ok(out)
3166}
3167
3168#[cfg(not(target_os = "windows"))]
3169fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3170    let output = Command::new("ps")
3171        .args(["-eo", "pid=,rss=,comm="])
3172        .output()
3173        .map_err(|e| format!("Failed to run ps: {e}"))?;
3174    if !output.status.success() {
3175        return Err("ps returned a non-success status.".to_string());
3176    }
3177
3178    let text = String::from_utf8_lossy(&output.stdout);
3179    let mut processes = Vec::new();
3180    for line in text.lines() {
3181        let cols: Vec<&str> = line.split_whitespace().collect();
3182        if cols.len() < 3 {
3183            continue;
3184        }
3185        let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
3186        else {
3187            continue;
3188        };
3189        processes.push(ProcessEntry {
3190            name: cols[2..].join(" "),
3191            pid,
3192            memory_bytes: rss_kib * 1024,
3193            cpu_seconds: None,
3194            cpu_percent: None,
3195            read_ops: None,
3196            write_ops: None,
3197            detail: None,
3198        });
3199    }
3200
3201    Ok(processes)
3202}
3203
3204fn extract_port_from_socket(value: &str) -> Option<u16> {
3205    let cleaned = value.trim().trim_matches(['[', ']']);
3206    let port_str = cleaned.rsplit(':').next()?;
3207    port_str.parse::<u16>().ok()
3208}
3209
3210fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3211    let mut summary = ListenerExposureSummary::default();
3212    for entry in listeners {
3213        let local = entry.local.to_ascii_lowercase();
3214        if is_loopback_listener(&local) {
3215            summary.loopback_only += 1;
3216        } else if is_wildcard_listener(&local) {
3217            summary.wildcard_public += 1;
3218        } else {
3219            summary.specific_bind += 1;
3220        }
3221    }
3222    summary
3223}
3224
3225fn is_loopback_listener(local: &str) -> bool {
3226    local.starts_with("127.")
3227        || local.starts_with("[::1]")
3228        || local.starts_with("::1")
3229        || local.starts_with("localhost:")
3230}
3231
3232fn is_wildcard_listener(local: &str) -> bool {
3233    local.starts_with("0.0.0.0:")
3234        || local.starts_with("[::]:")
3235        || local.starts_with(":::")
3236        || local == "*:*"
3237}
3238
3239struct GitState {
3240    root: PathBuf,
3241    branch: String,
3242    dirty_entries: usize,
3243}
3244
3245impl GitState {
3246    fn status_label(&self) -> String {
3247        if self.dirty_entries == 0 {
3248            "clean".to_string()
3249        } else {
3250            format!("dirty ({} changed path(s))", self.dirty_entries)
3251        }
3252    }
3253}
3254
3255fn inspect_git_state(path: &Path) -> Option<GitState> {
3256    let root = capture_first_line(
3257        "git",
3258        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3259    )?;
3260    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3261        .unwrap_or_else(|| "detached".to_string());
3262    let output = Command::new("git")
3263        .args(["-C", path.to_str()?, "status", "--short"])
3264        .output()
3265        .ok()?;
3266    if !output.status.success() {
3267        return None;
3268    }
3269    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3270    Some(GitState {
3271        root: PathBuf::from(root),
3272        branch,
3273        dirty_entries,
3274    })
3275}
3276
3277struct HematiteState {
3278    docs_count: usize,
3279    import_count: usize,
3280    report_count: usize,
3281    workspace_profile: bool,
3282}
3283
3284fn collect_hematite_state(path: &Path) -> HematiteState {
3285    let root = path.join(".hematite");
3286    HematiteState {
3287        docs_count: count_entries_if_exists(&root.join("docs")),
3288        import_count: count_entries_if_exists(&root.join("imports")),
3289        report_count: count_entries_if_exists(&root.join("reports")),
3290        workspace_profile: root.join("workspace_profile.json").exists(),
3291    }
3292}
3293
3294fn count_entries_if_exists(path: &Path) -> usize {
3295    if !path.exists() || !path.is_dir() {
3296        return 0;
3297    }
3298    fs::read_dir(path)
3299        .ok()
3300        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3301        .unwrap_or(0)
3302}
3303
3304fn collect_project_markers(path: &Path) -> Vec<String> {
3305    [
3306        "Cargo.toml",
3307        "package.json",
3308        "pyproject.toml",
3309        "go.mod",
3310        "justfile",
3311        "Makefile",
3312        ".git",
3313    ]
3314    .iter()
3315    .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3316    .collect()
3317}
3318
3319struct ReleaseArtifactState {
3320    version: String,
3321    portable_dir: bool,
3322    portable_zip: bool,
3323    setup_exe: bool,
3324}
3325
3326fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3327    let cargo_toml = path.join("Cargo.toml");
3328    if !cargo_toml.exists() {
3329        return None;
3330    }
3331    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3332    let version = [regex_line_capture(
3333        &cargo_text,
3334        r#"(?m)^version\s*=\s*"([^"]+)""#,
3335    )?]
3336    .concat();
3337    let dist_windows = path.join("dist").join("windows");
3338    let prefix = format!("Hematite-{}", version);
3339    Some(ReleaseArtifactState {
3340        version,
3341        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3342        portable_zip: dist_windows
3343            .join(format!("{}-portable.zip", prefix))
3344            .exists(),
3345        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3346    })
3347}
3348
3349fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3350    let regex = regex::Regex::new(pattern).ok()?;
3351    let captures = regex.captures(text)?;
3352    captures.get(1).map(|m| m.as_str().to_string())
3353}
3354
3355fn bool_label(value: bool) -> &'static str {
3356    if value {
3357        "yes"
3358    } else {
3359        "no"
3360    }
3361}
3362
3363fn collect_toolchains() -> ToolchainReport {
3364    let checks = [
3365        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3366        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3367        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3368        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3369        ToolCheck::new(
3370            "npm",
3371            &[
3372                CommandProbe::new("npm", &["--version"]),
3373                CommandProbe::new("npm.cmd", &["--version"]),
3374            ],
3375        ),
3376        ToolCheck::new(
3377            "pnpm",
3378            &[
3379                CommandProbe::new("pnpm", &["--version"]),
3380                CommandProbe::new("pnpm.cmd", &["--version"]),
3381            ],
3382        ),
3383        ToolCheck::new(
3384            "python",
3385            &[
3386                CommandProbe::new("python", &["--version"]),
3387                CommandProbe::new("python3", &["--version"]),
3388                CommandProbe::new("py", &["-3", "--version"]),
3389                CommandProbe::new("py", &["--version"]),
3390            ],
3391        ),
3392        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3393        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3394        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3395        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3396    ];
3397
3398    let mut found = Vec::new();
3399    let mut missing = Vec::new();
3400
3401    for check in checks {
3402        match check.detect() {
3403            Some(version) => found.push((check.label.to_string(), version)),
3404            None => missing.push(check.label.to_string()),
3405        }
3406    }
3407
3408    ToolchainReport { found, missing }
3409}
3410
3411fn collect_package_managers() -> PackageManagerReport {
3412    let checks = [
3413        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3414        ToolCheck::new(
3415            "npm",
3416            &[
3417                CommandProbe::new("npm", &["--version"]),
3418                CommandProbe::new("npm.cmd", &["--version"]),
3419            ],
3420        ),
3421        ToolCheck::new(
3422            "pnpm",
3423            &[
3424                CommandProbe::new("pnpm", &["--version"]),
3425                CommandProbe::new("pnpm.cmd", &["--version"]),
3426            ],
3427        ),
3428        ToolCheck::new(
3429            "pip",
3430            &[
3431                CommandProbe::new("python", &["-m", "pip", "--version"]),
3432                CommandProbe::new("python3", &["-m", "pip", "--version"]),
3433                CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3434                CommandProbe::new("py", &["-m", "pip", "--version"]),
3435                CommandProbe::new("pip", &["--version"]),
3436            ],
3437        ),
3438        ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3439        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3440        ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3441        ToolCheck::new(
3442            "choco",
3443            &[
3444                CommandProbe::new("choco", &["--version"]),
3445                CommandProbe::new("choco.exe", &["--version"]),
3446            ],
3447        ),
3448        ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3449    ];
3450
3451    let mut found = Vec::new();
3452    for check in checks {
3453        match check.detect() {
3454            Some(version) => found.push((check.label.to_string(), version)),
3455            None => {}
3456        }
3457    }
3458
3459    PackageManagerReport { found }
3460}
3461
3462#[derive(Clone)]
3463struct ToolCheck {
3464    label: &'static str,
3465    probes: Vec<CommandProbe>,
3466}
3467
3468impl ToolCheck {
3469    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3470        Self {
3471            label,
3472            probes: probes.to_vec(),
3473        }
3474    }
3475
3476    fn detect(&self) -> Option<String> {
3477        for probe in &self.probes {
3478            if let Some(output) = capture_first_line(probe.program, probe.args) {
3479                return Some(output);
3480            }
3481        }
3482        None
3483    }
3484}
3485
3486#[derive(Clone, Copy)]
3487struct CommandProbe {
3488    program: &'static str,
3489    args: &'static [&'static str],
3490}
3491
3492impl CommandProbe {
3493    const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
3494        Self { program, args }
3495    }
3496}
3497
3498fn build_env_doctor_findings(
3499    toolchains: &ToolchainReport,
3500    package_managers: &PackageManagerReport,
3501    path_stats: &PathAnalysis,
3502) -> Vec<String> {
3503    let found_tools = toolchains
3504        .found
3505        .iter()
3506        .map(|(label, _)| label.as_str())
3507        .collect::<HashSet<_>>();
3508    let found_managers = package_managers
3509        .found
3510        .iter()
3511        .map(|(label, _)| label.as_str())
3512        .collect::<HashSet<_>>();
3513
3514    let mut findings = Vec::new();
3515
3516    if path_stats.duplicate_entries.len() > 0 {
3517        findings.push(format!(
3518            "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3519            path_stats.duplicate_entries.len()
3520        ));
3521    }
3522    if path_stats.missing_entries.len() > 0 {
3523        findings.push(format!(
3524            "PATH contains {} entries that do not exist on disk.",
3525            path_stats.missing_entries.len()
3526        ));
3527    }
3528    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3529        findings.push(
3530            "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3531                .to_string(),
3532        );
3533    }
3534    if found_tools.contains("node")
3535        && !found_managers.contains("npm")
3536        && !found_managers.contains("pnpm")
3537    {
3538        findings.push(
3539            "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3540                .to_string(),
3541        );
3542    }
3543    if found_tools.contains("python")
3544        && !found_managers.contains("pip")
3545        && !found_managers.contains("uv")
3546        && !found_managers.contains("pipx")
3547    {
3548        findings.push(
3549            "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3550                .to_string(),
3551        );
3552    }
3553    let windows_manager_count = ["winget", "choco", "scoop"]
3554        .iter()
3555        .filter(|label| found_managers.contains(**label))
3556        .count();
3557    if windows_manager_count > 1 {
3558        findings.push(
3559            "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3560                .to_string(),
3561        );
3562    }
3563    if findings.is_empty() && !found_managers.is_empty() {
3564        findings.push(
3565            "Core package-manager coverage looks healthy for a normal developer workstation."
3566                .to_string(),
3567        );
3568    }
3569
3570    findings
3571}
3572
3573fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
3574    let output = std::process::Command::new(program)
3575        .args(args)
3576        .output()
3577        .ok()?;
3578    if !output.status.success() {
3579        return None;
3580    }
3581
3582    let stdout = if output.stdout.is_empty() {
3583        String::from_utf8_lossy(&output.stderr).into_owned()
3584    } else {
3585        String::from_utf8_lossy(&output.stdout).into_owned()
3586    };
3587
3588    stdout
3589        .lines()
3590        .map(str::trim)
3591        .find(|line| !line.is_empty())
3592        .map(|line| line.to_string())
3593}
3594
3595fn human_bytes(bytes: u64) -> String {
3596    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3597    let mut value = bytes as f64;
3598    let mut unit_index = 0usize;
3599
3600    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3601        value /= 1024.0;
3602        unit_index += 1;
3603    }
3604
3605    if unit_index == 0 {
3606        format!("{} {}", bytes, UNITS[unit_index])
3607    } else {
3608        format!("{value:.1} {}", UNITS[unit_index])
3609    }
3610}
3611
3612#[cfg(target_os = "windows")]
3613fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3614    let mut adapters = Vec::new();
3615    let mut current: Option<NetworkAdapter> = None;
3616    let mut pending_dns = false;
3617
3618    for raw_line in text.lines() {
3619        let line = raw_line.trim_end();
3620        let trimmed = line.trim();
3621        if trimmed.is_empty() {
3622            pending_dns = false;
3623            continue;
3624        }
3625
3626        if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3627            if let Some(adapter) = current.take() {
3628                adapters.push(adapter);
3629            }
3630            current = Some(NetworkAdapter {
3631                name: trimmed.trim_end_matches(':').to_string(),
3632                ..NetworkAdapter::default()
3633            });
3634            pending_dns = false;
3635            continue;
3636        }
3637
3638        let Some(adapter) = current.as_mut() else {
3639            continue;
3640        };
3641
3642        if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3643            adapter.disconnected = true;
3644        }
3645
3646        if let Some(value) = value_after_colon(trimmed) {
3647            let normalized = normalize_ipconfig_value(value);
3648            if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3649                adapter.ipv4.push(normalized);
3650                pending_dns = false;
3651            } else if trimmed.starts_with("IPv6 Address")
3652                || trimmed.starts_with("Temporary IPv6 Address")
3653                || trimmed.starts_with("Link-local IPv6 Address")
3654            {
3655                if !normalized.is_empty() {
3656                    adapter.ipv6.push(normalized);
3657                }
3658                pending_dns = false;
3659            } else if trimmed.starts_with("Default Gateway") {
3660                if !normalized.is_empty() {
3661                    adapter.gateways.push(normalized);
3662                }
3663                pending_dns = false;
3664            } else if trimmed.starts_with("DNS Servers") {
3665                if !normalized.is_empty() {
3666                    adapter.dns_servers.push(normalized);
3667                }
3668                pending_dns = true;
3669            } else {
3670                pending_dns = false;
3671            }
3672        } else if pending_dns {
3673            let normalized = normalize_ipconfig_value(trimmed);
3674            if !normalized.is_empty() {
3675                adapter.dns_servers.push(normalized);
3676            }
3677        }
3678    }
3679
3680    if let Some(adapter) = current.take() {
3681        adapters.push(adapter);
3682    }
3683
3684    for adapter in &mut adapters {
3685        dedup_vec(&mut adapter.ipv4);
3686        dedup_vec(&mut adapter.ipv6);
3687        dedup_vec(&mut adapter.gateways);
3688        dedup_vec(&mut adapter.dns_servers);
3689    }
3690
3691    adapters
3692}
3693
3694#[cfg(not(target_os = "windows"))]
3695fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3696    let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3697
3698    for line in text.lines() {
3699        let cols: Vec<&str> = line.split_whitespace().collect();
3700        if cols.len() < 4 {
3701            continue;
3702        }
3703        let name = cols[1].trim_end_matches(':').to_string();
3704        let family = cols[2];
3705        let addr = cols[3].split('/').next().unwrap_or("").to_string();
3706        let entry = adapters
3707            .entry(name.clone())
3708            .or_insert_with(|| NetworkAdapter {
3709                name,
3710                ..NetworkAdapter::default()
3711            });
3712        match family {
3713            "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3714            "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3715            _ => {}
3716        }
3717    }
3718
3719    adapters.into_values().collect()
3720}
3721
3722#[cfg(not(target_os = "windows"))]
3723fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3724    for line in text.lines() {
3725        let cols: Vec<&str> = line.split_whitespace().collect();
3726        if cols.len() < 5 {
3727            continue;
3728        }
3729        let gateway = cols
3730            .windows(2)
3731            .find(|pair| pair[0] == "via")
3732            .map(|pair| pair[1].to_string());
3733        let dev = cols
3734            .windows(2)
3735            .find(|pair| pair[0] == "dev")
3736            .map(|pair| pair[1]);
3737        if let (Some(gateway), Some(dev)) = (gateway, dev) {
3738            if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3739                adapter.gateways.push(gateway);
3740            }
3741        }
3742    }
3743
3744    for adapter in adapters {
3745        dedup_vec(&mut adapter.gateways);
3746    }
3747}
3748
3749#[cfg(not(target_os = "windows"))]
3750fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3751    let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3752        return;
3753    };
3754    let mut dns_servers = text
3755        .lines()
3756        .filter_map(|line| line.strip_prefix("nameserver "))
3757        .map(str::trim)
3758        .filter(|value| !value.is_empty())
3759        .map(|value| value.to_string())
3760        .collect::<Vec<_>>();
3761    dedup_vec(&mut dns_servers);
3762    if dns_servers.is_empty() {
3763        return;
3764    }
3765    for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3766        adapter.dns_servers = dns_servers.clone();
3767    }
3768}
3769
3770#[cfg(target_os = "windows")]
3771fn value_after_colon(line: &str) -> Option<&str> {
3772    line.split_once(':').map(|(_, value)| value.trim())
3773}
3774
3775#[cfg(target_os = "windows")]
3776fn normalize_ipconfig_value(value: &str) -> String {
3777    value
3778        .trim()
3779        .trim_end_matches("(Preferred)")
3780        .trim_end_matches("(Deprecated)")
3781        .trim()
3782        .trim_matches(['(', ')'])
3783        .trim()
3784        .to_string()
3785}
3786
3787#[cfg(target_os = "windows")]
3788fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3789    let mac_upper = mac.to_ascii_uppercase();
3790    if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3791        return true;
3792    }
3793
3794    ip == "255.255.255.255"
3795        || ip.starts_with("224.")
3796        || ip.starts_with("225.")
3797        || ip.starts_with("226.")
3798        || ip.starts_with("227.")
3799        || ip.starts_with("228.")
3800        || ip.starts_with("229.")
3801        || ip.starts_with("230.")
3802        || ip.starts_with("231.")
3803        || ip.starts_with("232.")
3804        || ip.starts_with("233.")
3805        || ip.starts_with("234.")
3806        || ip.starts_with("235.")
3807        || ip.starts_with("236.")
3808        || ip.starts_with("237.")
3809        || ip.starts_with("238.")
3810        || ip.starts_with("239.")
3811}
3812
3813fn dedup_vec(values: &mut Vec<String>) {
3814    let mut seen = HashSet::new();
3815    values.retain(|value| seen.insert(value.clone()));
3816}
3817
3818#[cfg(target_os = "windows")]
3819fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3820    let trimmed = text.trim();
3821    if trimmed.is_empty() {
3822        return Vec::new();
3823    }
3824
3825    let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3826        return Vec::new();
3827    };
3828    let entries = match value {
3829        Value::Array(items) => items,
3830        other => vec![other],
3831    };
3832
3833    let mut neighbors = Vec::new();
3834    for entry in entries {
3835        let ip = entry
3836            .get("IPAddress")
3837            .and_then(|v| v.as_str())
3838            .unwrap_or("")
3839            .to_string();
3840        if ip.is_empty() {
3841            continue;
3842        }
3843        let mac = entry
3844            .get("LinkLayerAddress")
3845            .and_then(|v| v.as_str())
3846            .unwrap_or("unknown")
3847            .to_string();
3848        let state = entry
3849            .get("State")
3850            .and_then(|v| v.as_str())
3851            .unwrap_or("unknown")
3852            .to_string();
3853        let iface = entry
3854            .get("InterfaceAlias")
3855            .and_then(|v| v.as_str())
3856            .unwrap_or("unknown")
3857            .to_string();
3858        if is_noise_lan_neighbor(&ip, &mac) {
3859            continue;
3860        }
3861        neighbors.push((ip, mac, state, iface));
3862    }
3863
3864    neighbors
3865}
3866
3867#[cfg(target_os = "windows")]
3868fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3869    let trimmed = text.trim();
3870    if trimmed.is_empty() {
3871        return Ok(Vec::new());
3872    }
3873
3874    let value: Value = serde_json::from_str(trimmed)
3875        .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3876    let entries = match value {
3877        Value::Array(items) => items,
3878        other => vec![other],
3879    };
3880
3881    let mut services = Vec::new();
3882    for entry in entries {
3883        let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3884            continue;
3885        };
3886        services.push(ServiceEntry {
3887            name: name.to_string(),
3888            status: entry
3889                .get("State")
3890                .and_then(|v| v.as_str())
3891                .unwrap_or("unknown")
3892                .to_string(),
3893            startup: entry
3894                .get("StartMode")
3895                .and_then(|v| v.as_str())
3896                .map(|v| v.to_string()),
3897            display_name: entry
3898                .get("DisplayName")
3899                .and_then(|v| v.as_str())
3900                .map(|v| v.to_string()),
3901            start_name: entry
3902                .get("StartName")
3903                .and_then(|v| v.as_str())
3904                .map(|v| v.to_string()),
3905        });
3906    }
3907
3908    Ok(services)
3909}
3910
3911#[cfg(target_os = "windows")]
3912fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3913    match node.cloned() {
3914        Some(Value::Array(items)) => items,
3915        Some(other) => vec![other],
3916        None => Vec::new(),
3917    }
3918}
3919
3920#[cfg(target_os = "windows")]
3921fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3922    windows_json_entries(node)
3923        .into_iter()
3924        .filter_map(|entry| {
3925            let name = entry
3926                .get("FriendlyName")
3927                .and_then(|v| v.as_str())
3928                .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3929                .unwrap_or("")
3930                .trim()
3931                .to_string();
3932            if name.is_empty() {
3933                return None;
3934            }
3935            Some(WindowsPnpDevice {
3936                name,
3937                status: entry
3938                    .get("Status")
3939                    .and_then(|v| v.as_str())
3940                    .unwrap_or("Unknown")
3941                    .trim()
3942                    .to_string(),
3943                problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3944                    entry
3945                        .get("Problem")
3946                        .and_then(|v| v.as_i64())
3947                        .map(|v| v as u64)
3948                }),
3949                class_name: entry
3950                    .get("Class")
3951                    .and_then(|v| v.as_str())
3952                    .map(|v| v.trim().to_string()),
3953                instance_id: entry
3954                    .get("InstanceId")
3955                    .and_then(|v| v.as_str())
3956                    .map(|v| v.trim().to_string()),
3957            })
3958        })
3959        .collect()
3960}
3961
3962#[cfg(target_os = "windows")]
3963fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
3964    windows_json_entries(node)
3965        .into_iter()
3966        .filter_map(|entry| {
3967            let name = entry
3968                .get("Name")
3969                .and_then(|v| v.as_str())
3970                .unwrap_or("")
3971                .trim()
3972                .to_string();
3973            if name.is_empty() {
3974                return None;
3975            }
3976            Some(WindowsSoundDevice {
3977                name,
3978                status: entry
3979                    .get("Status")
3980                    .and_then(|v| v.as_str())
3981                    .unwrap_or("Unknown")
3982                    .trim()
3983                    .to_string(),
3984                manufacturer: entry
3985                    .get("Manufacturer")
3986                    .and_then(|v| v.as_str())
3987                    .map(|v| v.trim().to_string()),
3988            })
3989        })
3990        .collect()
3991}
3992
3993#[cfg(target_os = "windows")]
3994fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
3995    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
3996        || device.problem.unwrap_or(0) != 0
3997}
3998
3999#[cfg(target_os = "windows")]
4000fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
4001    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
4002}
4003
4004#[cfg(target_os = "windows")]
4005fn is_microphone_like_name(name: &str) -> bool {
4006    let lower = name.to_ascii_lowercase();
4007    lower.contains("microphone")
4008        || lower.contains("mic")
4009        || lower.contains("input")
4010        || lower.contains("array")
4011        || lower.contains("capture")
4012        || lower.contains("record")
4013}
4014
4015#[cfg(target_os = "windows")]
4016fn is_bluetooth_like_name(name: &str) -> bool {
4017    let lower = name.to_ascii_lowercase();
4018    lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
4019}
4020
4021#[cfg(target_os = "windows")]
4022fn service_is_running(service: &ServiceEntry) -> bool {
4023    service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
4024}
4025
4026#[cfg(not(target_os = "windows"))]
4027fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
4028    let mut startup_modes = std::collections::HashMap::<String, String>::new();
4029    for line in startup_text.lines() {
4030        let cols: Vec<&str> = line.split_whitespace().collect();
4031        if cols.len() < 2 {
4032            continue;
4033        }
4034        startup_modes.insert(cols[0].to_string(), cols[1].to_string());
4035    }
4036
4037    let mut services = Vec::new();
4038    for line in status_text.lines() {
4039        let cols: Vec<&str> = line.split_whitespace().collect();
4040        if cols.len() < 4 {
4041            continue;
4042        }
4043        let unit = cols[0];
4044        let load = cols[1];
4045        let active = cols[2];
4046        let sub = cols[3];
4047        let description = if cols.len() > 4 {
4048            Some(cols[4..].join(" "))
4049        } else {
4050            None
4051        };
4052        services.push(ServiceEntry {
4053            name: unit.to_string(),
4054            status: format!("{}/{}", active, sub),
4055            startup: startup_modes
4056                .get(unit)
4057                .cloned()
4058                .or_else(|| Some(load.to_string())),
4059            display_name: description,
4060            start_name: None,
4061        });
4062    }
4063
4064    services
4065}
4066
4067// ── health_report ─────────────────────────────────────────────────────────────
4068
4069/// Synthesized system health report — runs multiple checks and returns a
4070/// plain-English tiered verdict suitable for both developers and non-technical
4071/// users who just want to know if their machine is okay.
4072fn inspect_health_report() -> Result<String, String> {
4073    let mut needs_fix: Vec<String> = Vec::new();
4074    let mut watch: Vec<String> = Vec::new();
4075    let mut good: Vec<String> = Vec::new();
4076    let mut tips: Vec<String> = Vec::new();
4077
4078    health_check_disk(&mut needs_fix, &mut watch, &mut good);
4079    health_check_memory(&mut watch, &mut good);
4080    health_check_tools(&mut watch, &mut good, &mut tips);
4081    health_check_recent_errors(&mut watch, &mut tips);
4082
4083    let overall = if !needs_fix.is_empty() {
4084        "ACTION REQUIRED"
4085    } else if !watch.is_empty() {
4086        "WORTH A LOOK"
4087    } else {
4088        "ALL GOOD"
4089    };
4090
4091    let mut out = format!("System Health Report — {overall}\n\n");
4092
4093    if !needs_fix.is_empty() {
4094        out.push_str("Needs fixing:\n");
4095        for item in &needs_fix {
4096            out.push_str(&format!("  [!] {item}\n"));
4097        }
4098        out.push('\n');
4099    }
4100    if !watch.is_empty() {
4101        out.push_str("Worth watching:\n");
4102        for item in &watch {
4103            out.push_str(&format!("  [-] {item}\n"));
4104        }
4105        out.push('\n');
4106    }
4107    if !good.is_empty() {
4108        out.push_str("Looking good:\n");
4109        for item in &good {
4110            out.push_str(&format!("  [+] {item}\n"));
4111        }
4112        out.push('\n');
4113    }
4114    if !tips.is_empty() {
4115        out.push_str("To dig deeper:\n");
4116        for tip in &tips {
4117            out.push_str(&format!("  {tip}\n"));
4118        }
4119    }
4120
4121    Ok(out.trim_end().to_string())
4122}
4123
4124fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4125    #[cfg(target_os = "windows")]
4126    {
4127        let script = r#"try {
4128    $d = Get-PSDrive C -ErrorAction Stop
4129    "$($d.Free)|$($d.Used)"
4130} catch { "ERR" }"#;
4131        if let Ok(out) = Command::new("powershell")
4132            .args(["-NoProfile", "-Command", script])
4133            .output()
4134        {
4135            let text = String::from_utf8_lossy(&out.stdout);
4136            let text = text.trim();
4137            if !text.starts_with("ERR") {
4138                let parts: Vec<&str> = text.split('|').collect();
4139                if parts.len() == 2 {
4140                    let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
4141                    let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
4142                    let total = free_bytes + used_bytes;
4143                    let free_gb = free_bytes / 1_073_741_824;
4144                    let pct_free = if total > 0 {
4145                        (free_bytes as f64 / total as f64 * 100.0) as u64
4146                    } else {
4147                        0
4148                    };
4149                    let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4150                    if free_gb < 5 {
4151                        needs_fix.push(format!(
4152                            "{msg} — very low. Free up space or your system may slow down or stop working."
4153                        ));
4154                    } else if free_gb < 15 {
4155                        watch.push(format!("{msg} — getting low, consider cleaning up."));
4156                    } else {
4157                        good.push(msg);
4158                    }
4159                    return;
4160                }
4161            }
4162        }
4163        watch.push("Disk: could not read free space from C: drive.".to_string());
4164    }
4165
4166    #[cfg(not(target_os = "windows"))]
4167    {
4168        if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4169            let text = String::from_utf8_lossy(&out.stdout);
4170            for line in text.lines().skip(1) {
4171                let cols: Vec<&str> = line.split_whitespace().collect();
4172                if cols.len() >= 5 {
4173                    let avail_str = cols[3].trim_end_matches('G');
4174                    let use_pct = cols[4].trim_end_matches('%');
4175                    let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4176                    let used_pct: u64 = use_pct.parse().unwrap_or(0);
4177                    let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4178                    if avail_gb < 5 {
4179                        needs_fix.push(format!(
4180                            "{msg} — very low. Free up space to prevent system issues."
4181                        ));
4182                    } else if avail_gb < 15 {
4183                        watch.push(format!("{msg} — getting low."));
4184                    } else {
4185                        good.push(msg);
4186                    }
4187                    return;
4188                }
4189            }
4190        }
4191        watch.push("Disk: could not determine free space.".to_string());
4192    }
4193}
4194
4195fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4196    #[cfg(target_os = "windows")]
4197    {
4198        let script = r#"try {
4199    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4200    "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4201} catch { "ERR" }"#;
4202        if let Ok(out) = Command::new("powershell")
4203            .args(["-NoProfile", "-Command", script])
4204            .output()
4205        {
4206            let text = String::from_utf8_lossy(&out.stdout);
4207            let text = text.trim();
4208            if !text.starts_with("ERR") {
4209                let parts: Vec<&str> = text.split('|').collect();
4210                if parts.len() == 2 {
4211                    let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4212                    let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4213                    if total_kb > 0 {
4214                        let free_gb = free_kb / 1_048_576;
4215                        let total_gb = total_kb / 1_048_576;
4216                        let free_pct = free_kb * 100 / total_kb;
4217                        let msg = format!(
4218                            "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4219                        );
4220                        if free_pct < 10 {
4221                            watch.push(format!(
4222                                "{msg} — very low. Close unused apps to free up memory."
4223                            ));
4224                        } else if free_pct < 25 {
4225                            watch.push(format!("{msg} — running a bit low."));
4226                        } else {
4227                            good.push(msg);
4228                        }
4229                        return;
4230                    }
4231                }
4232            }
4233        }
4234    }
4235
4236    #[cfg(not(target_os = "windows"))]
4237    {
4238        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4239            let mut total_kb = 0u64;
4240            let mut avail_kb = 0u64;
4241            for line in content.lines() {
4242                if line.starts_with("MemTotal:") {
4243                    total_kb = line
4244                        .split_whitespace()
4245                        .nth(1)
4246                        .and_then(|v| v.parse().ok())
4247                        .unwrap_or(0);
4248                } else if line.starts_with("MemAvailable:") {
4249                    avail_kb = line
4250                        .split_whitespace()
4251                        .nth(1)
4252                        .and_then(|v| v.parse().ok())
4253                        .unwrap_or(0);
4254                }
4255            }
4256            if total_kb > 0 {
4257                let free_gb = avail_kb / 1_048_576;
4258                let total_gb = total_kb / 1_048_576;
4259                let free_pct = avail_kb * 100 / total_kb;
4260                let msg =
4261                    format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4262                if free_pct < 10 {
4263                    watch.push(format!("{msg} — very low. Close unused apps."));
4264                } else if free_pct < 25 {
4265                    watch.push(format!("{msg} — running a bit low."));
4266                } else {
4267                    good.push(msg);
4268                }
4269            }
4270        }
4271    }
4272}
4273
4274fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4275    let tool_checks: &[(&str, &str, &str)] = &[
4276        ("git", "--version", "Git"),
4277        ("cargo", "--version", "Rust / Cargo"),
4278        ("node", "--version", "Node.js"),
4279        ("python", "--version", "Python"),
4280        ("python3", "--version", "Python 3"),
4281        ("npm", "--version", "npm"),
4282    ];
4283
4284    let mut found: Vec<String> = Vec::new();
4285    let mut missing: Vec<String> = Vec::new();
4286    let mut python_found = false;
4287
4288    for (cmd, arg, label) in tool_checks {
4289        if cmd.starts_with("python") && python_found {
4290            continue;
4291        }
4292        let ok = Command::new(cmd)
4293            .arg(arg)
4294            .stdout(std::process::Stdio::null())
4295            .stderr(std::process::Stdio::null())
4296            .status()
4297            .map(|s| s.success())
4298            .unwrap_or(false);
4299        if ok {
4300            found.push((*label).to_string());
4301            if cmd.starts_with("python") {
4302                python_found = true;
4303            }
4304        } else if !cmd.starts_with("python") || !python_found {
4305            missing.push((*label).to_string());
4306        }
4307    }
4308
4309    if !found.is_empty() {
4310        good.push(format!("Dev tools found: {}", found.join(", ")));
4311    }
4312    if !missing.is_empty() {
4313        watch.push(format!(
4314            "Not installed (or not on PATH): {} — only matters if you need them",
4315            missing.join(", ")
4316        ));
4317        tips.push(
4318            "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4319                .to_string(),
4320        );
4321    }
4322}
4323
4324fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4325    #[cfg(target_os = "windows")]
4326    {
4327        let script = r#"try {
4328    $cutoff = (Get-Date).AddHours(-24)
4329    $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4330    $count
4331} catch { "0" }"#;
4332        if let Ok(out) = Command::new("powershell")
4333            .args(["-NoProfile", "-Command", script])
4334            .output()
4335        {
4336            let text = String::from_utf8_lossy(&out.stdout);
4337            let count: u64 = text.trim().parse().unwrap_or(0);
4338            if count > 0 {
4339                watch.push(format!(
4340                    "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4341                    if count == 1 { "" } else { "s" }
4342                ));
4343                tips.push(
4344                    "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4345                        .to_string(),
4346                );
4347            }
4348        }
4349    }
4350
4351    #[cfg(not(target_os = "windows"))]
4352    {
4353        if let Ok(out) = Command::new("journalctl")
4354            .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4355            .output()
4356        {
4357            let text = String::from_utf8_lossy(&out.stdout);
4358            if !text.trim().is_empty() {
4359                watch.push("Critical/error entries found in the system journal.".to_string());
4360                tips.push(
4361                    "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4362                );
4363            }
4364        }
4365    }
4366}
4367
4368// ── log_check ─────────────────────────────────────────────────────────────────
4369
4370fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4371    let mut out = String::from("Host inspection: log_check\n\n");
4372
4373    #[cfg(target_os = "windows")]
4374    {
4375        // Pull recent critical/error events from Windows Application and System logs.
4376        let hours = lookback_hours.unwrap_or(24);
4377        out.push_str(&format!(
4378            "Checking System/Application logs from the last {} hours...\n\n",
4379            hours
4380        ));
4381
4382        let n = max_entries.clamp(1, 50);
4383        let script = format!(
4384            r#"try {{
4385    $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4386    if (-not $events) {{ "NO_EVENTS"; exit }}
4387    $events | Select-Object -First {n} | ForEach-Object {{
4388        $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4389        $line
4390    }}
4391}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4392            hours = hours,
4393            n = n
4394        );
4395        let output = Command::new("powershell")
4396            .args(["-NoProfile", "-Command", &script])
4397            .output()
4398            .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4399
4400        let raw = String::from_utf8_lossy(&output.stdout);
4401        let text = raw.trim();
4402
4403        if text.is_empty() || text == "NO_EVENTS" {
4404            out.push_str("No critical or error events found in Application/System logs.\n");
4405            return Ok(out.trim_end().to_string());
4406        }
4407        if text.starts_with("ERROR:") {
4408            out.push_str(&format!("Warning: event log query returned: {text}\n"));
4409            return Ok(out.trim_end().to_string());
4410        }
4411
4412        let mut count = 0usize;
4413        for line in text.lines() {
4414            let parts: Vec<&str> = line.splitn(4, '|').collect();
4415            if parts.len() == 4 {
4416                let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4417                out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4418                count += 1;
4419            }
4420        }
4421        out.push_str(&format!(
4422            "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4423        ));
4424    }
4425
4426    #[cfg(not(target_os = "windows"))]
4427    {
4428        let _ = lookback_hours;
4429        // Use journalctl on Linux/macOS if available.
4430        let n = max_entries.clamp(1, 50).to_string();
4431        let output = Command::new("journalctl")
4432            .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4433            .output();
4434
4435        match output {
4436            Ok(o) if o.status.success() => {
4437                let text = String::from_utf8_lossy(&o.stdout);
4438                let trimmed = text.trim();
4439                if trimmed.is_empty() || trimmed.contains("No entries") {
4440                    out.push_str("No critical or error entries found in the system journal.\n");
4441                } else {
4442                    out.push_str(trimmed);
4443                    out.push('\n');
4444                    out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4445                }
4446            }
4447            _ => {
4448                // Fallback: check /var/log/syslog or /var/log/messages
4449                let log_paths = ["/var/log/syslog", "/var/log/messages"];
4450                let mut found = false;
4451                for log_path in &log_paths {
4452                    if let Ok(content) = std::fs::read_to_string(log_path) {
4453                        let lines: Vec<&str> = content.lines().collect();
4454                        let tail: Vec<&str> = lines
4455                            .iter()
4456                            .rev()
4457                            .filter(|l| {
4458                                let l_lower = l.to_ascii_lowercase();
4459                                l_lower.contains("error") || l_lower.contains("crit")
4460                            })
4461                            .take(max_entries)
4462                            .copied()
4463                            .collect::<Vec<_>>()
4464                            .into_iter()
4465                            .rev()
4466                            .collect();
4467                        if !tail.is_empty() {
4468                            out.push_str(&format!("Source: {log_path}\n"));
4469                            for l in &tail {
4470                                out.push_str(l);
4471                                out.push('\n');
4472                            }
4473                            found = true;
4474                            break;
4475                        }
4476                    }
4477                }
4478                if !found {
4479                    out.push_str(
4480                        "journalctl not found and no readable syslog detected on this system.\n",
4481                    );
4482                }
4483            }
4484        }
4485    }
4486
4487    Ok(out.trim_end().to_string())
4488}
4489
4490// ── startup_items ─────────────────────────────────────────────────────────────
4491
4492fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4493    let mut out = String::from("Host inspection: startup_items\n\n");
4494
4495    #[cfg(target_os = "windows")]
4496    {
4497        // Query both HKLM and HKCU Run keys.
4498        let script = r#"
4499$hives = @(
4500    @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4501    @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4502    @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4503)
4504foreach ($h in $hives) {
4505    try {
4506        $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4507        $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4508            "$($h.Hive)|$($_.Name)|$($_.Value)"
4509        }
4510    } catch {}
4511}
4512"#;
4513        let output = Command::new("powershell")
4514            .args(["-NoProfile", "-Command", script])
4515            .output()
4516            .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4517
4518        let raw = String::from_utf8_lossy(&output.stdout);
4519        let text = raw.trim();
4520
4521        let entries: Vec<(String, String, String)> = text
4522            .lines()
4523            .filter_map(|l| {
4524                let parts: Vec<&str> = l.splitn(3, '|').collect();
4525                if parts.len() == 3 {
4526                    Some((
4527                        parts[0].to_string(),
4528                        parts[1].to_string(),
4529                        parts[2].to_string(),
4530                    ))
4531                } else {
4532                    None
4533                }
4534            })
4535            .take(max_entries)
4536            .collect();
4537
4538        if entries.is_empty() {
4539            out.push_str("No startup entries found in the Windows Run registry keys.\n");
4540        } else {
4541            out.push_str("Registry run keys (programs that start with Windows):\n\n");
4542            let mut last_hive = String::new();
4543            for (hive, name, value) in &entries {
4544                if *hive != last_hive {
4545                    out.push_str(&format!("[{}]\n", hive));
4546                    last_hive = hive.clone();
4547                }
4548                // Truncate very long values (paths with many args)
4549                let display = if value.len() > 100 {
4550                    format!("{}…", &value[..100])
4551                } else {
4552                    value.clone()
4553                };
4554                out.push_str(&format!("  {name}: {display}\n"));
4555            }
4556            out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4557        }
4558
4559        // 3. Unified Startup Command check (Task Manager style)
4560        let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { "  $($_.Name): $($_.Command) ($($_.Location))" }"#;
4561        if let Ok(unified_out) = Command::new("powershell")
4562            .args(["-NoProfile", "-Command", unified_script])
4563            .output()
4564        {
4565            let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4566            let trimmed = unified_text.trim();
4567            if !trimmed.is_empty() {
4568                out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4569                out.push_str(trimmed);
4570                out.push('\n');
4571            }
4572        }
4573    }
4574
4575    #[cfg(not(target_os = "windows"))]
4576    {
4577        // On Linux: systemd enabled services + cron @reboot entries.
4578        let output = Command::new("systemctl")
4579            .args([
4580                "list-unit-files",
4581                "--type=service",
4582                "--state=enabled",
4583                "--no-legend",
4584                "--no-pager",
4585                "--plain",
4586            ])
4587            .output();
4588
4589        match output {
4590            Ok(o) if o.status.success() => {
4591                let text = String::from_utf8_lossy(&o.stdout);
4592                let services: Vec<&str> = text
4593                    .lines()
4594                    .filter(|l| !l.trim().is_empty())
4595                    .take(max_entries)
4596                    .collect();
4597                if services.is_empty() {
4598                    out.push_str("No enabled systemd services found.\n");
4599                } else {
4600                    out.push_str("Enabled systemd services (run at boot):\n\n");
4601                    for s in &services {
4602                        out.push_str(&format!("  {s}\n"));
4603                    }
4604                    out.push_str(&format!(
4605                        "\nShowing {} of enabled services.\n",
4606                        services.len()
4607                    ));
4608                }
4609            }
4610            _ => {
4611                out.push_str(
4612                    "systemctl not found on this system. Cannot enumerate startup services.\n",
4613                );
4614            }
4615        }
4616
4617        // Check @reboot cron entries.
4618        if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4619            let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4620            let reboot_entries: Vec<&str> = cron_text
4621                .lines()
4622                .filter(|l| l.trim_start().starts_with("@reboot"))
4623                .collect();
4624            if !reboot_entries.is_empty() {
4625                out.push_str("\nCron @reboot entries:\n");
4626                for e in reboot_entries {
4627                    out.push_str(&format!("  {e}\n"));
4628                }
4629            }
4630        }
4631    }
4632
4633    Ok(out.trim_end().to_string())
4634}
4635
4636fn inspect_os_config() -> Result<String, String> {
4637    let mut out = String::from("Host inspection: OS Configuration\n\n");
4638
4639    #[cfg(target_os = "windows")]
4640    {
4641        // Power Plan
4642        if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4643            let power_str = String::from_utf8_lossy(&power_out.stdout);
4644            out.push_str("=== Power Plan ===\n");
4645            out.push_str(power_str.trim());
4646            out.push_str("\n\n");
4647        }
4648
4649        // Firewall Status
4650        let fw_script =
4651            "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4652        if let Ok(fw_out) = Command::new("powershell")
4653            .args(["-NoProfile", "-Command", fw_script])
4654            .output()
4655        {
4656            let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4657            out.push_str("=== Firewall Profiles ===\n");
4658            out.push_str(fw_str.trim());
4659            out.push_str("\n\n");
4660        }
4661
4662        // System Uptime
4663        let uptime_script =
4664            "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4665        if let Ok(uptime_out) = Command::new("powershell")
4666            .args(["-NoProfile", "-Command", uptime_script])
4667            .output()
4668        {
4669            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4670            out.push_str("=== System Uptime (Last Boot) ===\n");
4671            out.push_str(uptime_str.trim());
4672            out.push_str("\n\n");
4673        }
4674    }
4675
4676    #[cfg(not(target_os = "windows"))]
4677    {
4678        // Uptime
4679        if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4680            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4681            out.push_str("=== System Uptime ===\n");
4682            out.push_str(uptime_str.trim());
4683            out.push_str("\n\n");
4684        }
4685
4686        // Firewall (ufw status if available)
4687        if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4688            let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4689            if !ufw_str.trim().is_empty() {
4690                out.push_str("=== Firewall (UFW) ===\n");
4691                out.push_str(ufw_str.trim());
4692                out.push_str("\n\n");
4693            }
4694        }
4695    }
4696    Ok(out.trim_end().to_string())
4697}
4698
4699pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
4700    let action = args
4701        .get("action")
4702        .and_then(|v| v.as_str())
4703        .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
4704
4705    let target = args
4706        .get("target")
4707        .and_then(|v| v.as_str())
4708        .unwrap_or("")
4709        .trim();
4710
4711    if target.is_empty() && action != "clear_temp" {
4712        return Err("Missing required argument: 'target' for this action".to_string());
4713    }
4714
4715    match action {
4716        "install_package" => {
4717            #[cfg(target_os = "windows")]
4718            {
4719                let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
4720                match Command::new("powershell")
4721                    .args(["-NoProfile", "-Command", &cmd])
4722                    .output()
4723                {
4724                    Ok(out) => Ok(format!(
4725                        "Executed remediation (winget install):\n{}",
4726                        String::from_utf8_lossy(&out.stdout)
4727                    )),
4728                    Err(e) => Err(format!("Failed to run winget: {}", e)),
4729                }
4730            }
4731            #[cfg(not(target_os = "windows"))]
4732            {
4733                Err(
4734                    "install_package via wrapper is only supported on Windows currently (winget)"
4735                        .to_string(),
4736                )
4737            }
4738        }
4739        "restart_service" => {
4740            #[cfg(target_os = "windows")]
4741            {
4742                let cmd = format!("Restart-Service -Name {} -Force", target);
4743                match Command::new("powershell")
4744                    .args(["-NoProfile", "-Command", &cmd])
4745                    .output()
4746                {
4747                    Ok(out) => {
4748                        let err_str = String::from_utf8_lossy(&out.stderr);
4749                        if !err_str.is_empty() {
4750                            return Err(format!("Error restarting service:\n{}", err_str));
4751                        }
4752                        Ok(format!("Successfully restarted service: {}", target))
4753                    }
4754                    Err(e) => Err(format!("Failed to restart service: {}", e)),
4755                }
4756            }
4757            #[cfg(not(target_os = "windows"))]
4758            {
4759                Err(
4760                    "restart_service via wrapper is only supported on Windows currently"
4761                        .to_string(),
4762                )
4763            }
4764        }
4765        "clear_temp" => {
4766            #[cfg(target_os = "windows")]
4767            {
4768                let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
4769                match Command::new("powershell")
4770                    .args(["-NoProfile", "-Command", cmd])
4771                    .output()
4772                {
4773                    Ok(_) => Ok("Successfully cleared temporary files".to_string()),
4774                    Err(e) => Err(format!("Failed to clear temp: {}", e)),
4775                }
4776            }
4777            #[cfg(not(target_os = "windows"))]
4778            {
4779                Err("clear_temp via wrapper is only supported on Windows currently".to_string())
4780            }
4781        }
4782        other => Err(format!("Unknown remediation action: {}", other)),
4783    }
4784}
4785
4786// ── storage ───────────────────────────────────────────────────────────────────
4787
4788fn inspect_storage(max_entries: usize) -> Result<String, String> {
4789    let mut out = String::from("Host inspection: storage\n\n");
4790    let _ = max_entries; // used by non-Windows branch
4791
4792    // ── Drive overview ────────────────────────────────────────────────────────
4793    out.push_str("Drives:\n");
4794
4795    #[cfg(target_os = "windows")]
4796    {
4797        let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
4798    $free = $_.Free
4799    $used = $_.Used
4800    if ($free -eq $null) { $free = 0 }
4801    if ($used -eq $null) { $used = 0 }
4802    $total = $free + $used
4803    "$($_.Name)|$free|$used|$total"
4804}"#;
4805        match Command::new("powershell")
4806            .args(["-NoProfile", "-Command", script])
4807            .output()
4808        {
4809            Ok(o) => {
4810                let text = String::from_utf8_lossy(&o.stdout);
4811                let mut drive_count = 0usize;
4812                for line in text.lines() {
4813                    let parts: Vec<&str> = line.trim().split('|').collect();
4814                    if parts.len() == 4 {
4815                        let name = parts[0];
4816                        let free: u64 = parts[1].parse().unwrap_or(0);
4817                        let total: u64 = parts[3].parse().unwrap_or(0);
4818                        if total == 0 {
4819                            continue;
4820                        }
4821                        let free_gb = free / 1_073_741_824;
4822                        let total_gb = total / 1_073_741_824;
4823                        let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
4824                        let bar_len = 20usize;
4825                        let filled = (used_pct as usize * bar_len / 100).min(bar_len);
4826                        let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
4827                        let warn = if free_gb < 5 {
4828                            " [!] CRITICALLY LOW"
4829                        } else if free_gb < 15 {
4830                            " [-] LOW"
4831                        } else {
4832                            ""
4833                        };
4834                        out.push_str(&format!(
4835                            "  {name}:  [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
4836                        ));
4837                        drive_count += 1;
4838                    }
4839                }
4840                if drive_count == 0 {
4841                    out.push_str("  (could not enumerate drives)\n");
4842                }
4843            }
4844            Err(e) => out.push_str(&format!("  (drive scan failed: {e})\n")),
4845        }
4846
4847        // ── Real-time Performance (Latency) ──────────────────────────────────
4848        let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
4849        match Command::new("powershell")
4850            .args(["-NoProfile", "-Command", latency_script])
4851            .output()
4852        {
4853            Ok(o) => {
4854                out.push_str("\nReal-time Disk Intensity:\n");
4855                let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
4856                if !text.is_empty() {
4857                    out.push_str(&format!("  Average Disk Queue Length: {text}\n"));
4858                    if let Ok(q) = text.parse::<f64>() {
4859                        if q > 2.0 {
4860                            out.push_str(
4861                                "  [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
4862                            );
4863                        } else {
4864                            out.push_str("  [~] Disk latency is within healthy bounds.\n");
4865                        }
4866                    }
4867                } else {
4868                    out.push_str("  Average Disk Queue Length: unavailable\n");
4869                }
4870            }
4871            Err(_) => {
4872                out.push_str("\nReal-time Disk Intensity:\n");
4873                out.push_str("  Average Disk Queue Length: unavailable\n");
4874            }
4875        }
4876    }
4877
4878    #[cfg(not(target_os = "windows"))]
4879    {
4880        match Command::new("df")
4881            .args(["-h", "--output=target,size,avail,pcent"])
4882            .output()
4883        {
4884            Ok(o) => {
4885                let text = String::from_utf8_lossy(&o.stdout);
4886                let mut count = 0usize;
4887                for line in text.lines().skip(1) {
4888                    let cols: Vec<&str> = line.split_whitespace().collect();
4889                    if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
4890                        out.push_str(&format!(
4891                            "  {}  size: {}  avail: {}  used: {}\n",
4892                            cols[0], cols[1], cols[2], cols[3]
4893                        ));
4894                        count += 1;
4895                        if count >= max_entries {
4896                            break;
4897                        }
4898                    }
4899                }
4900            }
4901            Err(e) => out.push_str(&format!("  (df failed: {e})\n")),
4902        }
4903    }
4904
4905    // ── Large developer cache directories ─────────────────────────────────────
4906    out.push_str("\nLarge developer cache directories (if present):\n");
4907
4908    #[cfg(target_os = "windows")]
4909    {
4910        let home = std::env::var("USERPROFILE").unwrap_or_default();
4911        let check_dirs: &[(&str, &str)] = &[
4912            ("Temp", r"AppData\Local\Temp"),
4913            ("npm cache", r"AppData\Roaming\npm-cache"),
4914            ("Cargo registry", r".cargo\registry"),
4915            ("Cargo git", r".cargo\git"),
4916            ("pip cache", r"AppData\Local\pip\cache"),
4917            ("Yarn cache", r"AppData\Local\Yarn\Cache"),
4918            (".rustup toolchains", r".rustup\toolchains"),
4919            ("node_modules (home)", r"node_modules"),
4920        ];
4921
4922        let mut found_any = false;
4923        for (label, rel) in check_dirs {
4924            let full = format!(r"{}\{}", home, rel);
4925            let path = std::path::Path::new(&full);
4926            if path.exists() {
4927                // Quick size estimate via PowerShell (non-blocking cap at 5s)
4928                let size_script = format!(
4929                    r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
4930                    full.replace('\'', "''")
4931                );
4932                let size_mb = Command::new("powershell")
4933                    .args(["-NoProfile", "-Command", &size_script])
4934                    .output()
4935                    .ok()
4936                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
4937                    .unwrap_or_else(|| "?".to_string());
4938                out.push_str(&format!("  {label}: {size_mb} MB  ({full})\n"));
4939                found_any = true;
4940            }
4941        }
4942        if !found_any {
4943            out.push_str("  (none of the common cache directories found)\n");
4944        }
4945
4946        out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
4947    }
4948
4949    #[cfg(not(target_os = "windows"))]
4950    {
4951        let home = std::env::var("HOME").unwrap_or_default();
4952        let check_dirs: &[(&str, &str)] = &[
4953            ("npm cache", ".npm"),
4954            ("Cargo registry", ".cargo/registry"),
4955            ("pip cache", ".cache/pip"),
4956            (".rustup toolchains", ".rustup/toolchains"),
4957            ("Yarn cache", ".cache/yarn"),
4958        ];
4959        let mut found_any = false;
4960        for (label, rel) in check_dirs {
4961            let full = format!("{}/{}", home, rel);
4962            if std::path::Path::new(&full).exists() {
4963                let size = Command::new("du")
4964                    .args(["-sh", &full])
4965                    .output()
4966                    .ok()
4967                    .map(|o| {
4968                        let s = String::from_utf8_lossy(&o.stdout);
4969                        s.split_whitespace().next().unwrap_or("?").to_string()
4970                    })
4971                    .unwrap_or_else(|| "?".to_string());
4972                out.push_str(&format!("  {label}: {size}  ({full})\n"));
4973                found_any = true;
4974            }
4975        }
4976        if !found_any {
4977            out.push_str("  (none of the common cache directories found)\n");
4978        }
4979    }
4980
4981    Ok(out.trim_end().to_string())
4982}
4983
4984// ── hardware ──────────────────────────────────────────────────────────────────
4985
4986fn inspect_hardware() -> Result<String, String> {
4987    let mut out = String::from("Host inspection: hardware\n\n");
4988
4989    #[cfg(target_os = "windows")]
4990    {
4991        // CPU
4992        let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
4993    "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
4994} | Select-Object -First 1"#;
4995        if let Ok(o) = Command::new("powershell")
4996            .args(["-NoProfile", "-Command", cpu_script])
4997            .output()
4998        {
4999            let text = String::from_utf8_lossy(&o.stdout);
5000            let text = text.trim();
5001            let parts: Vec<&str> = text.split('|').collect();
5002            if parts.len() == 4 {
5003                out.push_str(&format!(
5004                    "CPU: {}\n  {} physical cores, {} logical processors, {:.1} GHz\n\n",
5005                    parts[0],
5006                    parts[1],
5007                    parts[2],
5008                    parts[3].parse::<f32>().unwrap_or(0.0)
5009                ));
5010            } else {
5011                out.push_str(&format!("CPU: {text}\n\n"));
5012            }
5013        }
5014
5015        // RAM (total installed + speed)
5016        let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
5017$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
5018$speed = ($sticks | Select-Object -First 1).Speed
5019"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
5020        if let Ok(o) = Command::new("powershell")
5021            .args(["-NoProfile", "-Command", ram_script])
5022            .output()
5023        {
5024            let text = String::from_utf8_lossy(&o.stdout);
5025            out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
5026        }
5027
5028        // GPU(s)
5029        let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
5030    "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5031}"#;
5032        if let Ok(o) = Command::new("powershell")
5033            .args(["-NoProfile", "-Command", gpu_script])
5034            .output()
5035        {
5036            let text = String::from_utf8_lossy(&o.stdout);
5037            let lines: Vec<&str> = text.lines().collect();
5038            if !lines.is_empty() {
5039                out.push_str("GPU(s):\n");
5040                for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5041                    let parts: Vec<&str> = line.trim().split('|').collect();
5042                    if parts.len() == 3 {
5043                        let res = if parts[2] == "x" || parts[2].starts_with('0') {
5044                            String::new()
5045                        } else {
5046                            format!(" — {}@display", parts[2])
5047                        };
5048                        out.push_str(&format!(
5049                            "  {}\n    Driver: {}{}\n",
5050                            parts[0], parts[1], res
5051                        ));
5052                    } else {
5053                        out.push_str(&format!("  {}\n", line.trim()));
5054                    }
5055                }
5056                out.push('\n');
5057            }
5058        }
5059
5060        // Motherboard + BIOS + Virtualization
5061        let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5062$bios = Get-CimInstance Win32_BIOS
5063$cs = Get-CimInstance Win32_ComputerSystem
5064$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5065$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5066"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5067        if let Ok(o) = Command::new("powershell")
5068            .args(["-NoProfile", "-Command", mb_script])
5069            .output()
5070        {
5071            let text = String::from_utf8_lossy(&o.stdout);
5072            let text = text.trim().trim_matches('"');
5073            let parts: Vec<&str> = text.split('|').collect();
5074            if parts.len() == 4 {
5075                out.push_str(&format!(
5076                    "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5077                    parts[0].trim(),
5078                    parts[1].trim(),
5079                    parts[2].trim(),
5080                    parts[3].trim()
5081                ));
5082            }
5083        }
5084
5085        // Display(s)
5086        let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5087    "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5088}"#;
5089        if let Ok(o) = Command::new("powershell")
5090            .args(["-NoProfile", "-Command", disp_script])
5091            .output()
5092        {
5093            let text = String::from_utf8_lossy(&o.stdout);
5094            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5095            if !lines.is_empty() {
5096                out.push_str("Display(s):\n");
5097                for line in &lines {
5098                    let parts: Vec<&str> = line.trim().split('|').collect();
5099                    if parts.len() == 2 {
5100                        out.push_str(&format!("  {} — {}\n", parts[0].trim(), parts[1]));
5101                    }
5102                }
5103            }
5104        }
5105    }
5106
5107    #[cfg(not(target_os = "windows"))]
5108    {
5109        // CPU via /proc/cpuinfo
5110        if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5111            let model = content
5112                .lines()
5113                .find(|l| l.starts_with("model name"))
5114                .and_then(|l| l.split(':').nth(1))
5115                .map(str::trim)
5116                .unwrap_or("unknown");
5117            let cores = content
5118                .lines()
5119                .filter(|l| l.starts_with("processor"))
5120                .count();
5121            out.push_str(&format!("CPU: {model}\n  {cores} logical processors\n\n"));
5122        }
5123
5124        // RAM
5125        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5126            let total_kb: u64 = content
5127                .lines()
5128                .find(|l| l.starts_with("MemTotal:"))
5129                .and_then(|l| l.split_whitespace().nth(1))
5130                .and_then(|v| v.parse().ok())
5131                .unwrap_or(0);
5132            let total_gb = total_kb / 1_048_576;
5133            out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
5134        }
5135
5136        // GPU via lspci
5137        if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5138            let text = String::from_utf8_lossy(&o.stdout);
5139            let gpu_lines: Vec<&str> = text
5140                .lines()
5141                .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5142                .collect();
5143            if !gpu_lines.is_empty() {
5144                out.push_str("GPU(s):\n");
5145                for l in gpu_lines {
5146                    out.push_str(&format!("  {l}\n"));
5147                }
5148                out.push('\n');
5149            }
5150        }
5151
5152        // DMI/BIOS info
5153        if let Ok(o) = Command::new("dmidecode")
5154            .args(["-t", "baseboard", "-t", "bios"])
5155            .output()
5156        {
5157            let text = String::from_utf8_lossy(&o.stdout);
5158            out.push_str("Motherboard/BIOS:\n");
5159            for line in text
5160                .lines()
5161                .filter(|l| {
5162                    l.contains("Manufacturer:")
5163                        || l.contains("Product Name:")
5164                        || l.contains("Version:")
5165                })
5166                .take(6)
5167            {
5168                out.push_str(&format!("  {}\n", line.trim()));
5169            }
5170        }
5171    }
5172
5173    Ok(out.trim_end().to_string())
5174}
5175
5176// ── updates ───────────────────────────────────────────────────────────────────
5177
5178fn inspect_updates() -> Result<String, String> {
5179    let mut out = String::from("Host inspection: updates\n\n");
5180
5181    #[cfg(target_os = "windows")]
5182    {
5183        // Last installed update via COM
5184        let script = r#"
5185try {
5186    $sess = New-Object -ComObject Microsoft.Update.Session
5187    $searcher = $sess.CreateUpdateSearcher()
5188    $count = $searcher.GetTotalHistoryCount()
5189    if ($count -gt 0) {
5190        $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5191        $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5192    } else { "NONE|LAST_INSTALL" }
5193} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5194"#;
5195        if let Ok(o) = Command::new("powershell")
5196            .args(["-NoProfile", "-Command", script])
5197            .output()
5198        {
5199            let raw = String::from_utf8_lossy(&o.stdout);
5200            let text = raw.trim();
5201            if text.starts_with("ERROR:") {
5202                out.push_str("Last update install: (unable to query)\n");
5203            } else if text.contains("NONE") {
5204                out.push_str("Last update install: No update history found\n");
5205            } else {
5206                let date = text.replace("|LAST_INSTALL", "");
5207                out.push_str(&format!("Last update install: {date}\n"));
5208            }
5209        }
5210
5211        // Pending updates count
5212        let pending_script = r#"
5213try {
5214    $sess = New-Object -ComObject Microsoft.Update.Session
5215    $searcher = $sess.CreateUpdateSearcher()
5216    $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5217    $results.Updates.Count.ToString() + "|PENDING"
5218} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5219"#;
5220        if let Ok(o) = Command::new("powershell")
5221            .args(["-NoProfile", "-Command", pending_script])
5222            .output()
5223        {
5224            let raw = String::from_utf8_lossy(&o.stdout);
5225            let text = raw.trim();
5226            if text.starts_with("ERROR:") {
5227                out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5228            } else {
5229                let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5230                if count == 0 {
5231                    out.push_str("Pending updates: Up to date — no updates waiting\n");
5232                } else if count > 0 {
5233                    out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5234                    out.push_str(
5235                        "  → Open Windows Update (Settings > Windows Update) to install\n",
5236                    );
5237                }
5238            }
5239        }
5240
5241        // Windows Update service state
5242        let svc_script = r#"
5243$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5244if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5245"#;
5246        if let Ok(o) = Command::new("powershell")
5247            .args(["-NoProfile", "-Command", svc_script])
5248            .output()
5249        {
5250            let raw = String::from_utf8_lossy(&o.stdout);
5251            let status = raw.trim();
5252            out.push_str(&format!("Windows Update service: {status}\n"));
5253        }
5254    }
5255
5256    #[cfg(not(target_os = "windows"))]
5257    {
5258        let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5259        let mut found = false;
5260        if let Ok(o) = apt_out {
5261            let text = String::from_utf8_lossy(&o.stdout);
5262            let lines: Vec<&str> = text
5263                .lines()
5264                .filter(|l| l.contains('/') && !l.contains("Listing"))
5265                .collect();
5266            if !lines.is_empty() {
5267                out.push_str(&format!(
5268                    "{} package(s) can be upgraded (apt)\n",
5269                    lines.len()
5270                ));
5271                out.push_str("  → Run: sudo apt upgrade\n");
5272                found = true;
5273            }
5274        }
5275        if !found {
5276            if let Ok(o) = Command::new("dnf")
5277                .args(["check-update", "--quiet"])
5278                .output()
5279            {
5280                let text = String::from_utf8_lossy(&o.stdout);
5281                let count = text
5282                    .lines()
5283                    .filter(|l| !l.is_empty() && !l.starts_with('!'))
5284                    .count();
5285                if count > 0 {
5286                    out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5287                    out.push_str("  → Run: sudo dnf upgrade\n");
5288                } else {
5289                    out.push_str("System is up to date.\n");
5290                }
5291            } else {
5292                out.push_str("Could not query package manager for updates.\n");
5293            }
5294        }
5295    }
5296
5297    Ok(out.trim_end().to_string())
5298}
5299
5300// ── security ──────────────────────────────────────────────────────────────────
5301
5302fn inspect_security() -> Result<String, String> {
5303    let mut out = String::from("Host inspection: security\n\n");
5304
5305    #[cfg(target_os = "windows")]
5306    {
5307        // Windows Defender status
5308        let defender_script = r#"
5309try {
5310    $status = Get-MpComputerStatus -ErrorAction Stop
5311    "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5312} catch { "ERROR:" + $_.Exception.Message }
5313"#;
5314        if let Ok(o) = Command::new("powershell")
5315            .args(["-NoProfile", "-Command", defender_script])
5316            .output()
5317        {
5318            let raw = String::from_utf8_lossy(&o.stdout);
5319            let text = raw.trim();
5320            if text.starts_with("ERROR:") {
5321                out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5322            } else {
5323                let get = |key: &str| -> String {
5324                    text.split('|')
5325                        .find(|s| s.starts_with(key))
5326                        .and_then(|s| s.splitn(2, ':').nth(1))
5327                        .unwrap_or("unknown")
5328                        .to_string()
5329                };
5330                let rtp = get("RTP");
5331                let last_scan = {
5332                    // SCAN field has a colon in the time, so grab everything after "SCAN:"
5333                    text.split('|')
5334                        .find(|s| s.starts_with("SCAN:"))
5335                        .and_then(|s| s.get(5..))
5336                        .unwrap_or("unknown")
5337                        .to_string()
5338                };
5339                let def_ver = get("VER");
5340                let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5341
5342                let rtp_label = if rtp == "True" {
5343                    "ENABLED"
5344                } else {
5345                    "DISABLED [!]"
5346                };
5347                out.push_str(&format!(
5348                    "Windows Defender real-time protection: {rtp_label}\n"
5349                ));
5350                out.push_str(&format!("Last quick scan: {last_scan}\n"));
5351                out.push_str(&format!("Signature version: {def_ver}\n"));
5352                if age_days >= 0 {
5353                    let freshness = if age_days == 0 {
5354                        "up to date".to_string()
5355                    } else if age_days <= 3 {
5356                        format!("{age_days} day(s) old — OK")
5357                    } else if age_days <= 7 {
5358                        format!("{age_days} day(s) old — consider updating")
5359                    } else {
5360                        format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5361                    };
5362                    out.push_str(&format!("Signature age: {freshness}\n"));
5363                }
5364                if rtp != "True" {
5365                    out.push_str(
5366                        "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5367                    );
5368                    out.push_str(
5369                        "    → Open Windows Security > Virus & threat protection to re-enable.\n",
5370                    );
5371                }
5372            }
5373        }
5374
5375        out.push('\n');
5376
5377        // Windows Firewall state
5378        let fw_script = r#"
5379try {
5380    Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5381} catch { "ERROR:" + $_.Exception.Message }
5382"#;
5383        if let Ok(o) = Command::new("powershell")
5384            .args(["-NoProfile", "-Command", fw_script])
5385            .output()
5386        {
5387            let raw = String::from_utf8_lossy(&o.stdout);
5388            let text = raw.trim();
5389            if !text.starts_with("ERROR:") && !text.is_empty() {
5390                out.push_str("Windows Firewall:\n");
5391                for line in text.lines() {
5392                    if let Some((name, enabled)) = line.split_once(':') {
5393                        let state = if enabled.trim() == "True" {
5394                            "ON"
5395                        } else {
5396                            "OFF [!]"
5397                        };
5398                        out.push_str(&format!("  {name}: {state}\n"));
5399                    }
5400                }
5401                out.push('\n');
5402            }
5403        }
5404
5405        // Windows activation status
5406        let act_script = r#"
5407try {
5408    $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5409    if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5410} catch { "UNKNOWN" }
5411"#;
5412        if let Ok(o) = Command::new("powershell")
5413            .args(["-NoProfile", "-Command", act_script])
5414            .output()
5415        {
5416            let raw = String::from_utf8_lossy(&o.stdout);
5417            match raw.trim() {
5418                "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5419                "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5420                _ => out.push_str("Windows activation: Unable to determine\n"),
5421            }
5422        }
5423
5424        // UAC state
5425        let uac_script = r#"
5426$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5427if ($val -eq 1) { "ON" } else { "OFF" }
5428"#;
5429        if let Ok(o) = Command::new("powershell")
5430            .args(["-NoProfile", "-Command", uac_script])
5431            .output()
5432        {
5433            let raw = String::from_utf8_lossy(&o.stdout);
5434            let state = raw.trim();
5435            let label = if state == "ON" {
5436                "Enabled"
5437            } else {
5438                "DISABLED [!] — recommended to re-enable via secpol.msc"
5439            };
5440            out.push_str(&format!("UAC (User Account Control): {label}\n"));
5441        }
5442    }
5443
5444    #[cfg(not(target_os = "windows"))]
5445    {
5446        if let Ok(o) = Command::new("ufw").arg("status").output() {
5447            let text = String::from_utf8_lossy(&o.stdout);
5448            out.push_str(&format!(
5449                "UFW: {}\n",
5450                text.lines().next().unwrap_or("unknown")
5451            ));
5452        }
5453        if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5454            if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5455                out.push_str(&format!("{line}\n"));
5456            }
5457        }
5458    }
5459
5460    Ok(out.trim_end().to_string())
5461}
5462
5463// ── pending_reboot ────────────────────────────────────────────────────────────
5464
5465fn inspect_pending_reboot() -> Result<String, String> {
5466    let mut out = String::from("Host inspection: pending_reboot\n\n");
5467
5468    #[cfg(target_os = "windows")]
5469    {
5470        let script = r#"
5471$reasons = @()
5472if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5473    $reasons += "Windows Update requires a restart"
5474}
5475if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5476    $reasons += "Windows component install/update requires a restart"
5477}
5478$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5479if ($pfro -and $pfro.PendingFileRenameOperations) {
5480    $reasons += "Pending file rename operations (driver or system file replacement)"
5481}
5482if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5483"#;
5484        let output = Command::new("powershell")
5485            .args(["-NoProfile", "-Command", script])
5486            .output()
5487            .map_err(|e| format!("pending_reboot: {e}"))?;
5488
5489        let raw = String::from_utf8_lossy(&output.stdout);
5490        let text = raw.trim();
5491
5492        if text == "NO_REBOOT_NEEDED" {
5493            out.push_str("No restart required — system is up to date and stable.\n");
5494        } else if text.is_empty() {
5495            out.push_str("Could not determine reboot status.\n");
5496        } else {
5497            out.push_str("[!] A system restart is pending:\n\n");
5498            for reason in text.split("|REASON|") {
5499                out.push_str(&format!("  • {}\n", reason.trim()));
5500            }
5501            out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5502        }
5503    }
5504
5505    #[cfg(not(target_os = "windows"))]
5506    {
5507        if std::path::Path::new("/var/run/reboot-required").exists() {
5508            out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5509            if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5510                out.push_str("Packages requiring restart:\n");
5511                for p in pkgs.lines().take(10) {
5512                    out.push_str(&format!("  • {p}\n"));
5513                }
5514            }
5515        } else {
5516            out.push_str("No restart required.\n");
5517        }
5518    }
5519
5520    Ok(out.trim_end().to_string())
5521}
5522
5523// ── disk_health ───────────────────────────────────────────────────────────────
5524
5525fn inspect_disk_health() -> Result<String, String> {
5526    let mut out = String::from("Host inspection: disk_health\n\n");
5527
5528    #[cfg(target_os = "windows")]
5529    {
5530        let script = r#"
5531try {
5532    $disks = Get-PhysicalDisk -ErrorAction Stop
5533    foreach ($d in $disks) {
5534        $size_gb = [math]::Round($d.Size / 1GB, 0)
5535        $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5536    }
5537} catch { "ERROR:" + $_.Exception.Message }
5538"#;
5539        let output = Command::new("powershell")
5540            .args(["-NoProfile", "-Command", script])
5541            .output()
5542            .map_err(|e| format!("disk_health: {e}"))?;
5543
5544        let raw = String::from_utf8_lossy(&output.stdout);
5545        let text = raw.trim();
5546
5547        if text.starts_with("ERROR:") {
5548            out.push_str(&format!("Unable to query disk health: {text}\n"));
5549            out.push_str("This may require running as administrator.\n");
5550        } else if text.is_empty() {
5551            out.push_str("No physical disks found.\n");
5552        } else {
5553            out.push_str("Physical Drive Health:\n\n");
5554            for line in text.lines() {
5555                let parts: Vec<&str> = line.splitn(5, '|').collect();
5556                if parts.len() >= 4 {
5557                    let name = parts[0];
5558                    let media = parts[1];
5559                    let size = parts[2];
5560                    let health = parts[3];
5561                    let op_status = parts.get(4).unwrap_or(&"");
5562                    let health_label = match health.trim() {
5563                        "Healthy" => "OK",
5564                        "Warning" => "[!] WARNING",
5565                        "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5566                        other => other,
5567                    };
5568                    out.push_str(&format!("  {name}\n"));
5569                    out.push_str(&format!("    Type: {media} | Size: {size}\n"));
5570                    out.push_str(&format!("    Health: {health_label}\n"));
5571                    if !op_status.is_empty() {
5572                        out.push_str(&format!("    Status: {op_status}\n"));
5573                    }
5574                    out.push('\n');
5575                }
5576            }
5577        }
5578
5579        // SMART failure prediction (best-effort, may need admin)
5580        let smart_script = r#"
5581try {
5582    Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5583        ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5584} catch { "" }
5585"#;
5586        if let Ok(o) = Command::new("powershell")
5587            .args(["-NoProfile", "-Command", smart_script])
5588            .output()
5589        {
5590            let raw2 = String::from_utf8_lossy(&o.stdout);
5591            let text2 = raw2.trim();
5592            if !text2.is_empty() {
5593                let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5594                if failures.is_empty() {
5595                    out.push_str("SMART failure prediction: No failures predicted\n");
5596                } else {
5597                    out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5598                    for f in failures {
5599                        let name = f.split('|').next().unwrap_or(f);
5600                        out.push_str(&format!("  • {name}\n"));
5601                    }
5602                    out.push_str(
5603                        "\nBack up your data immediately and replace the failing drive.\n",
5604                    );
5605                }
5606            }
5607        }
5608    }
5609
5610    #[cfg(not(target_os = "windows"))]
5611    {
5612        if let Ok(o) = Command::new("lsblk")
5613            .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5614            .output()
5615        {
5616            let text = String::from_utf8_lossy(&o.stdout);
5617            out.push_str("Block devices:\n");
5618            out.push_str(text.trim());
5619            out.push('\n');
5620        }
5621        if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5622            let devices = String::from_utf8_lossy(&scan.stdout);
5623            for dev_line in devices.lines().take(4) {
5624                let dev = dev_line.split_whitespace().next().unwrap_or("");
5625                if dev.is_empty() {
5626                    continue;
5627                }
5628                if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5629                    let health = String::from_utf8_lossy(&o.stdout);
5630                    if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5631                    {
5632                        out.push_str(&format!("{dev}: {}\n", line.trim()));
5633                    }
5634                }
5635            }
5636        } else {
5637            out.push_str("(install smartmontools for SMART health data)\n");
5638        }
5639    }
5640
5641    Ok(out.trim_end().to_string())
5642}
5643
5644// ── battery ───────────────────────────────────────────────────────────────────
5645
5646fn inspect_battery() -> Result<String, String> {
5647    let mut out = String::from("Host inspection: battery\n\n");
5648
5649    #[cfg(target_os = "windows")]
5650    {
5651        let script = r#"
5652try {
5653    $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5654    if (-not $bats) { "NO_BATTERY"; exit }
5655    
5656    # Modern Battery Health (Cycle count + Capacity health)
5657    $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5658    $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue 
5659    $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5660
5661    foreach ($b in $bats) {
5662        $state = switch ($b.BatteryStatus) {
5663            1 { "Discharging" }
5664            2 { "AC Power (Fully Charged)" }
5665            3 { "AC Power (Charging)" }
5666            default { "Status $($b.BatteryStatus)" }
5667        }
5668        
5669        $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5670        $health = if ($static -and $full) {
5671             [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5672        } else { "unknown" }
5673
5674        $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5675    }
5676} catch { "ERROR:" + $_.Exception.Message }
5677"#;
5678        let output = Command::new("powershell")
5679            .args(["-NoProfile", "-Command", script])
5680            .output()
5681            .map_err(|e| format!("battery: {e}"))?;
5682
5683        let raw = String::from_utf8_lossy(&output.stdout);
5684        let text = raw.trim();
5685
5686        if text == "NO_BATTERY" {
5687            out.push_str("No battery detected — desktop or AC-only system.\n");
5688            return Ok(out.trim_end().to_string());
5689        }
5690        if text.starts_with("ERROR:") {
5691            out.push_str(&format!("Unable to query battery: {text}\n"));
5692            return Ok(out.trim_end().to_string());
5693        }
5694
5695        for line in text.lines() {
5696            let parts: Vec<&str> = line.split('|').collect();
5697            if parts.len() == 5 {
5698                let name = parts[0];
5699                let charge: i64 = parts[1].parse().unwrap_or(-1);
5700                let state = parts[2];
5701                let cycles = parts[3];
5702                let health = parts[4];
5703
5704                out.push_str(&format!("Battery: {name}\n"));
5705                if charge >= 0 {
5706                    let bar_filled = (charge as usize * 20) / 100;
5707                    out.push_str(&format!(
5708                        "  Charge: [{}{}] {}%\n",
5709                        "#".repeat(bar_filled),
5710                        ".".repeat(20 - bar_filled),
5711                        charge
5712                    ));
5713                }
5714                out.push_str(&format!("  Status: {state}\n"));
5715                out.push_str(&format!("  Cycles: {cycles}\n"));
5716                out.push_str(&format!(
5717                    "  Health: {health}% (Actual vs Design Capacity)\n\n"
5718                ));
5719            }
5720        }
5721    }
5722
5723    #[cfg(not(target_os = "windows"))]
5724    {
5725        let power_path = std::path::Path::new("/sys/class/power_supply");
5726        let mut found = false;
5727        if power_path.exists() {
5728            if let Ok(entries) = std::fs::read_dir(power_path) {
5729                for entry in entries.flatten() {
5730                    let p = entry.path();
5731                    if let Ok(t) = std::fs::read_to_string(p.join("type")) {
5732                        if t.trim() == "Battery" {
5733                            found = true;
5734                            let name = p
5735                                .file_name()
5736                                .unwrap_or_default()
5737                                .to_string_lossy()
5738                                .to_string();
5739                            out.push_str(&format!("Battery: {name}\n"));
5740                            let read = |f: &str| {
5741                                std::fs::read_to_string(p.join(f))
5742                                    .ok()
5743                                    .map(|s| s.trim().to_string())
5744                            };
5745                            if let Some(cap) = read("capacity") {
5746                                out.push_str(&format!("  Charge: {cap}%\n"));
5747                            }
5748                            if let Some(status) = read("status") {
5749                                out.push_str(&format!("  Status: {status}\n"));
5750                            }
5751                            if let (Some(full), Some(design)) =
5752                                (read("energy_full"), read("energy_full_design"))
5753                            {
5754                                if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
5755                                {
5756                                    if d > 0.0 {
5757                                        out.push_str(&format!(
5758                                            "  Wear level: {:.1}% of design capacity\n",
5759                                            (f / d) * 100.0
5760                                        ));
5761                                    }
5762                                }
5763                            }
5764                        }
5765                    }
5766                }
5767            }
5768        }
5769        if !found {
5770            out.push_str("No battery found.\n");
5771        }
5772    }
5773
5774    Ok(out.trim_end().to_string())
5775}
5776
5777// ── recent_crashes ────────────────────────────────────────────────────────────
5778
5779fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
5780    let mut out = String::from("Host inspection: recent_crashes\n\n");
5781    let n = max_entries.clamp(1, 30);
5782
5783    #[cfg(target_os = "windows")]
5784    {
5785        // BSODs / unexpected shutdowns (EventID 41 = kernel power, 1001 = BugCheck)
5786        let bsod_script = format!(
5787            r#"
5788try {{
5789    $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
5790    if ($events) {{
5791        $events | ForEach-Object {{
5792            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5793        }}
5794    }} else {{ "NO_BSOD" }}
5795}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5796        );
5797
5798        if let Ok(o) = Command::new("powershell")
5799            .args(["-NoProfile", "-Command", &bsod_script])
5800            .output()
5801        {
5802            let raw = String::from_utf8_lossy(&o.stdout);
5803            let text = raw.trim();
5804            if text == "NO_BSOD" {
5805                out.push_str("System crashes (BSOD/kernel): None in recent history\n");
5806            } else if text.starts_with("ERROR:") {
5807                out.push_str("System crashes: unable to query\n");
5808            } else {
5809                out.push_str("System crashes / unexpected shutdowns:\n");
5810                for line in text.lines() {
5811                    let parts: Vec<&str> = line.splitn(3, '|').collect();
5812                    if parts.len() >= 3 {
5813                        let time = parts[0];
5814                        let id = parts[1];
5815                        let msg = parts[2];
5816                        let label = if id == "41" {
5817                            "Unexpected shutdown"
5818                        } else {
5819                            "BSOD (BugCheck)"
5820                        };
5821                        out.push_str(&format!("  [{time}] {label}: {msg}\n"));
5822                    }
5823                }
5824                out.push('\n');
5825            }
5826        }
5827
5828        // Application crashes (EventID 1000 = app crash, 1002 = app hang)
5829        let app_script = format!(
5830            r#"
5831try {{
5832    $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
5833    if ($crashes) {{
5834        $crashes | ForEach-Object {{
5835            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5836        }}
5837    }} else {{ "NO_CRASHES" }}
5838}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
5839        );
5840
5841        if let Ok(o) = Command::new("powershell")
5842            .args(["-NoProfile", "-Command", &app_script])
5843            .output()
5844        {
5845            let raw = String::from_utf8_lossy(&o.stdout);
5846            let text = raw.trim();
5847            if text == "NO_CRASHES" {
5848                out.push_str("Application crashes: None in recent history\n");
5849            } else if text.starts_with("ERROR_APP:") {
5850                out.push_str("Application crashes: unable to query\n");
5851            } else {
5852                out.push_str("Application crashes:\n");
5853                for line in text.lines().take(n) {
5854                    let parts: Vec<&str> = line.splitn(2, '|').collect();
5855                    if parts.len() >= 2 {
5856                        out.push_str(&format!("  [{}] {}\n", parts[0], parts[1]));
5857                    }
5858                }
5859            }
5860        }
5861    }
5862
5863    #[cfg(not(target_os = "windows"))]
5864    {
5865        let n_str = n.to_string();
5866        if let Ok(o) = Command::new("journalctl")
5867            .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
5868            .output()
5869        {
5870            let text = String::from_utf8_lossy(&o.stdout);
5871            let trimmed = text.trim();
5872            if trimmed.is_empty() || trimmed.contains("No entries") {
5873                out.push_str("No kernel panics or critical crashes found.\n");
5874            } else {
5875                out.push_str("Kernel critical events:\n");
5876                out.push_str(trimmed);
5877                out.push('\n');
5878            }
5879        }
5880        if let Ok(o) = Command::new("coredumpctl")
5881            .args(["list", "--no-pager"])
5882            .output()
5883        {
5884            let text = String::from_utf8_lossy(&o.stdout);
5885            let count = text
5886                .lines()
5887                .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
5888                .count();
5889            if count > 0 {
5890                out.push_str(&format!(
5891                    "\nCore dumps on file: {count}\n  → Run: coredumpctl list\n"
5892                ));
5893            }
5894        }
5895    }
5896
5897    Ok(out.trim_end().to_string())
5898}
5899
5900// ── scheduled_tasks ───────────────────────────────────────────────────────────
5901
5902fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
5903    let mut out = String::from("Host inspection: scheduled_tasks\n\n");
5904    let n = max_entries.clamp(1, 30);
5905
5906    #[cfg(target_os = "windows")]
5907    {
5908        let script = format!(
5909            r#"
5910try {{
5911    $tasks = Get-ScheduledTask -ErrorAction Stop |
5912        Where-Object {{ $_.State -ne 'Disabled' }} |
5913        ForEach-Object {{
5914            $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
5915            $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
5916                $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
5917            }} else {{ "never" }}
5918            $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
5919            $exec = ($_.Actions | Select-Object -First 1).Execute
5920            if (-not $exec) {{ $exec = "(no exec)" }}
5921            $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
5922        }}
5923    $tasks | Select-Object -First {n}
5924}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5925        );
5926
5927        let output = Command::new("powershell")
5928            .args(["-NoProfile", "-Command", &script])
5929            .output()
5930            .map_err(|e| format!("scheduled_tasks: {e}"))?;
5931
5932        let raw = String::from_utf8_lossy(&output.stdout);
5933        let text = raw.trim();
5934
5935        if text.starts_with("ERROR:") {
5936            out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
5937        } else if text.is_empty() {
5938            out.push_str("No active scheduled tasks found.\n");
5939        } else {
5940            out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
5941            for line in text.lines() {
5942                let parts: Vec<&str> = line.splitn(6, '|').collect();
5943                if parts.len() >= 5 {
5944                    let name = parts[0];
5945                    let path = parts[1];
5946                    let state = parts[2];
5947                    let last = parts[3];
5948                    let res = parts[4];
5949                    let exec = parts.get(5).unwrap_or(&"").trim();
5950                    let display_path = path.trim_matches('\\');
5951                    let display_path = if display_path.is_empty() {
5952                        "Root"
5953                    } else {
5954                        display_path
5955                    };
5956                    out.push_str(&format!("  {name} [{display_path}]\n"));
5957                    out.push_str(&format!(
5958                        "    State: {state} | Last run: {last} | Result: {res}\n"
5959                    ));
5960                    if !exec.is_empty() && exec != "(no exec)" {
5961                        let short = if exec.len() > 80 { &exec[..80] } else { exec };
5962                        out.push_str(&format!("    Runs: {short}\n"));
5963                    }
5964                }
5965            }
5966        }
5967    }
5968
5969    #[cfg(not(target_os = "windows"))]
5970    {
5971        if let Ok(o) = Command::new("systemctl")
5972            .args(["list-timers", "--no-pager", "--all"])
5973            .output()
5974        {
5975            let text = String::from_utf8_lossy(&o.stdout);
5976            out.push_str("Systemd timers:\n");
5977            for l in text
5978                .lines()
5979                .filter(|l| {
5980                    !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
5981                })
5982                .take(n)
5983            {
5984                out.push_str(&format!("  {l}\n"));
5985            }
5986            out.push('\n');
5987        }
5988        if let Ok(o) = Command::new("crontab").arg("-l").output() {
5989            let text = String::from_utf8_lossy(&o.stdout);
5990            let jobs: Vec<&str> = text
5991                .lines()
5992                .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
5993                .collect();
5994            if !jobs.is_empty() {
5995                out.push_str("User crontab:\n");
5996                for j in jobs.iter().take(n) {
5997                    out.push_str(&format!("  {j}\n"));
5998                }
5999            }
6000        }
6001    }
6002
6003    Ok(out.trim_end().to_string())
6004}
6005
6006// ── dev_conflicts ─────────────────────────────────────────────────────────────
6007
6008fn inspect_dev_conflicts() -> Result<String, String> {
6009    let mut out = String::from("Host inspection: dev_conflicts\n\n");
6010    let mut conflicts: Vec<String> = Vec::new();
6011    let mut notes: Vec<String> = Vec::new();
6012
6013    // ── Node.js / version managers ────────────────────────────────────────────
6014    {
6015        let node_ver = Command::new("node")
6016            .arg("--version")
6017            .output()
6018            .ok()
6019            .and_then(|o| String::from_utf8(o.stdout).ok())
6020            .map(|s| s.trim().to_string());
6021        let nvm_active = Command::new("nvm")
6022            .arg("current")
6023            .output()
6024            .ok()
6025            .and_then(|o| String::from_utf8(o.stdout).ok())
6026            .map(|s| s.trim().to_string())
6027            .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
6028        let fnm_active = Command::new("fnm")
6029            .arg("current")
6030            .output()
6031            .ok()
6032            .and_then(|o| String::from_utf8(o.stdout).ok())
6033            .map(|s| s.trim().to_string())
6034            .filter(|s| !s.is_empty() && !s.contains("none"));
6035        let volta_active = Command::new("volta")
6036            .args(["which", "node"])
6037            .output()
6038            .ok()
6039            .and_then(|o| String::from_utf8(o.stdout).ok())
6040            .map(|s| s.trim().to_string())
6041            .filter(|s| !s.is_empty());
6042
6043        out.push_str("Node.js:\n");
6044        if let Some(ref v) = node_ver {
6045            out.push_str(&format!("  Active: {v}\n"));
6046        } else {
6047            out.push_str("  Not installed\n");
6048        }
6049        let managers: Vec<&str> = [
6050            nvm_active.as_deref(),
6051            fnm_active.as_deref(),
6052            volta_active.as_deref(),
6053        ]
6054        .iter()
6055        .filter_map(|x| *x)
6056        .collect();
6057        if managers.len() > 1 {
6058            conflicts.push(format!(
6059                "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
6060            ));
6061        } else if !managers.is_empty() {
6062            out.push_str(&format!("  Version manager: {}\n", managers[0]));
6063        }
6064        out.push('\n');
6065    }
6066
6067    // ── Python ────────────────────────────────────────────────────────────────
6068    {
6069        let py3 = Command::new("python3")
6070            .arg("--version")
6071            .output()
6072            .ok()
6073            .and_then(|o| {
6074                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6075                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6076                let v = if stdout.is_empty() { stderr } else { stdout };
6077                if v.is_empty() {
6078                    None
6079                } else {
6080                    Some(v)
6081                }
6082            });
6083        let py = Command::new("python")
6084            .arg("--version")
6085            .output()
6086            .ok()
6087            .and_then(|o| {
6088                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6089                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6090                let v = if stdout.is_empty() { stderr } else { stdout };
6091                if v.is_empty() {
6092                    None
6093                } else {
6094                    Some(v)
6095                }
6096            });
6097        let pyenv = Command::new("pyenv")
6098            .arg("version")
6099            .output()
6100            .ok()
6101            .and_then(|o| String::from_utf8(o.stdout).ok())
6102            .map(|s| s.trim().to_string())
6103            .filter(|s| !s.is_empty());
6104        let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6105
6106        out.push_str("Python:\n");
6107        match (&py3, &py) {
6108            (Some(v3), Some(v)) if v3 != v => {
6109                out.push_str(&format!("  python3: {v3}\n  python:  {v}\n"));
6110                if v.contains("2.") {
6111                    conflicts.push(
6112                        "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6113                    );
6114                } else {
6115                    notes.push(
6116                        "python and python3 resolve to different minor versions.".to_string(),
6117                    );
6118                }
6119            }
6120            (Some(v3), None) => out.push_str(&format!("  python3: {v3}\n")),
6121            (None, Some(v)) => out.push_str(&format!("  python: {v}\n")),
6122            (Some(v3), Some(_)) => out.push_str(&format!("  {v3}\n")),
6123            (None, None) => out.push_str("  Not installed\n"),
6124        }
6125        if let Some(ref pe) = pyenv {
6126            out.push_str(&format!("  pyenv: {pe}\n"));
6127        }
6128        if let Some(env) = conda_env {
6129            if env == "base" {
6130                notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6131            } else {
6132                out.push_str(&format!("  conda env: {env}\n"));
6133            }
6134        }
6135        out.push('\n');
6136    }
6137
6138    // ── Rust / Cargo ──────────────────────────────────────────────────────────
6139    {
6140        let toolchain = Command::new("rustup")
6141            .args(["show", "active-toolchain"])
6142            .output()
6143            .ok()
6144            .and_then(|o| String::from_utf8(o.stdout).ok())
6145            .map(|s| s.trim().to_string())
6146            .filter(|s| !s.is_empty());
6147        let cargo_ver = Command::new("cargo")
6148            .arg("--version")
6149            .output()
6150            .ok()
6151            .and_then(|o| String::from_utf8(o.stdout).ok())
6152            .map(|s| s.trim().to_string());
6153        let rustc_ver = Command::new("rustc")
6154            .arg("--version")
6155            .output()
6156            .ok()
6157            .and_then(|o| String::from_utf8(o.stdout).ok())
6158            .map(|s| s.trim().to_string());
6159
6160        out.push_str("Rust:\n");
6161        if let Some(ref t) = toolchain {
6162            out.push_str(&format!("  Active toolchain: {t}\n"));
6163        }
6164        if let Some(ref c) = cargo_ver {
6165            out.push_str(&format!("  {c}\n"));
6166        }
6167        if let Some(ref r) = rustc_ver {
6168            out.push_str(&format!("  {r}\n"));
6169        }
6170        if cargo_ver.is_none() && rustc_ver.is_none() {
6171            out.push_str("  Not installed\n");
6172        }
6173
6174        // Detect system rust that might shadow rustup
6175        #[cfg(not(target_os = "windows"))]
6176        if let Ok(o) = Command::new("which").arg("rustc").output() {
6177            let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6178            if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6179                conflicts.push(format!(
6180                    "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6181                ));
6182            }
6183        }
6184        out.push('\n');
6185    }
6186
6187    // ── Git ───────────────────────────────────────────────────────────────────
6188    {
6189        let git_ver = Command::new("git")
6190            .arg("--version")
6191            .output()
6192            .ok()
6193            .and_then(|o| String::from_utf8(o.stdout).ok())
6194            .map(|s| s.trim().to_string());
6195        out.push_str("Git:\n");
6196        if let Some(ref v) = git_ver {
6197            out.push_str(&format!("  {v}\n"));
6198            let email = Command::new("git")
6199                .args(["config", "--global", "user.email"])
6200                .output()
6201                .ok()
6202                .and_then(|o| String::from_utf8(o.stdout).ok())
6203                .map(|s| s.trim().to_string());
6204            if let Some(ref e) = email {
6205                if e.is_empty() {
6206                    notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6207                } else {
6208                    out.push_str(&format!("  user.email: {e}\n"));
6209                }
6210            }
6211            let gpg_sign = Command::new("git")
6212                .args(["config", "--global", "commit.gpgsign"])
6213                .output()
6214                .ok()
6215                .and_then(|o| String::from_utf8(o.stdout).ok())
6216                .map(|s| s.trim().to_string());
6217            if gpg_sign.as_deref() == Some("true") {
6218                let key = Command::new("git")
6219                    .args(["config", "--global", "user.signingkey"])
6220                    .output()
6221                    .ok()
6222                    .and_then(|o| String::from_utf8(o.stdout).ok())
6223                    .map(|s| s.trim().to_string());
6224                if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6225                    conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6226                }
6227            }
6228        } else {
6229            out.push_str("  Not installed\n");
6230        }
6231        out.push('\n');
6232    }
6233
6234    // ── PATH duplicates ───────────────────────────────────────────────────────
6235    {
6236        let path_env = std::env::var("PATH").unwrap_or_default();
6237        let sep = if cfg!(windows) { ';' } else { ':' };
6238        let mut seen = HashSet::new();
6239        let mut dupes: Vec<String> = Vec::new();
6240        for p in path_env.split(sep) {
6241            let norm = p.trim().to_lowercase();
6242            if !norm.is_empty() && !seen.insert(norm) {
6243                dupes.push(p.to_string());
6244            }
6245        }
6246        if !dupes.is_empty() {
6247            let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6248            notes.push(format!(
6249                "Duplicate PATH entries: {} {}",
6250                shown.join(", "),
6251                if dupes.len() > 3 {
6252                    format!("+{} more", dupes.len() - 3)
6253                } else {
6254                    String::new()
6255                }
6256            ));
6257        }
6258    }
6259
6260    // ── Summary ───────────────────────────────────────────────────────────────
6261    if conflicts.is_empty() && notes.is_empty() {
6262        out.push_str("No conflicts detected — dev environment looks clean.\n");
6263    } else {
6264        if !conflicts.is_empty() {
6265            out.push_str("CONFLICTS:\n");
6266            for c in &conflicts {
6267                out.push_str(&format!("  [!] {c}\n"));
6268            }
6269            out.push('\n');
6270        }
6271        if !notes.is_empty() {
6272            out.push_str("NOTES:\n");
6273            for n in &notes {
6274                out.push_str(&format!("  [-] {n}\n"));
6275            }
6276        }
6277    }
6278
6279    Ok(out.trim_end().to_string())
6280}
6281
6282// ── connectivity ──────────────────────────────────────────────────────────────
6283
6284fn inspect_connectivity() -> Result<String, String> {
6285    let mut out = String::from("Host inspection: connectivity\n\n");
6286
6287    #[cfg(target_os = "windows")]
6288    {
6289        let inet_script = r#"
6290try {
6291    $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6292    if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6293} catch { "ERROR:" + $_.Exception.Message }
6294"#;
6295        if let Ok(o) = Command::new("powershell")
6296            .args(["-NoProfile", "-Command", inet_script])
6297            .output()
6298        {
6299            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6300            match text.as_str() {
6301                "REACHABLE" => out.push_str("Internet: reachable\n"),
6302                "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6303                _ => out.push_str(&format!(
6304                    "Internet: {}\n",
6305                    text.trim_start_matches("ERROR:").trim()
6306                )),
6307            }
6308        }
6309
6310        let dns_script = r#"
6311try {
6312    Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6313    "DNS:ok"
6314} catch { "DNS:fail:" + $_.Exception.Message }
6315"#;
6316        if let Ok(o) = Command::new("powershell")
6317            .args(["-NoProfile", "-Command", dns_script])
6318            .output()
6319        {
6320            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6321            if text == "DNS:ok" {
6322                out.push_str("DNS: resolving correctly\n");
6323            } else {
6324                let detail = text.trim_start_matches("DNS:fail:").trim();
6325                out.push_str(&format!("DNS: failed — {}\n", detail));
6326            }
6327        }
6328
6329        let gw_script = r#"
6330(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6331"#;
6332        if let Ok(o) = Command::new("powershell")
6333            .args(["-NoProfile", "-Command", gw_script])
6334            .output()
6335        {
6336            let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6337            if !gw.is_empty() && gw != "0.0.0.0" {
6338                out.push_str(&format!("Default gateway: {}\n", gw));
6339            }
6340        }
6341    }
6342
6343    #[cfg(not(target_os = "windows"))]
6344    {
6345        let reachable = Command::new("ping")
6346            .args(["-c", "1", "-W", "2", "8.8.8.8"])
6347            .output()
6348            .map(|o| o.status.success())
6349            .unwrap_or(false);
6350        out.push_str(if reachable {
6351            "Internet: reachable\n"
6352        } else {
6353            "Internet: unreachable\n"
6354        });
6355        let dns_ok = Command::new("getent")
6356            .args(["hosts", "dns.google"])
6357            .output()
6358            .map(|o| o.status.success())
6359            .unwrap_or(false);
6360        out.push_str(if dns_ok {
6361            "DNS: resolving correctly\n"
6362        } else {
6363            "DNS: failed\n"
6364        });
6365        if let Ok(o) = Command::new("ip")
6366            .args(["route", "show", "default"])
6367            .output()
6368        {
6369            let text = String::from_utf8_lossy(&o.stdout);
6370            if let Some(line) = text.lines().next() {
6371                out.push_str(&format!("Default gateway: {}\n", line.trim()));
6372            }
6373        }
6374    }
6375
6376    Ok(out.trim_end().to_string())
6377}
6378
6379// ── wifi ──────────────────────────────────────────────────────────────────────
6380
6381fn inspect_wifi() -> Result<String, String> {
6382    let mut out = String::from("Host inspection: wifi\n\n");
6383
6384    #[cfg(target_os = "windows")]
6385    {
6386        let output = Command::new("netsh")
6387            .args(["wlan", "show", "interfaces"])
6388            .output()
6389            .map_err(|e| format!("wifi: {e}"))?;
6390        let text = String::from_utf8_lossy(&output.stdout).to_string();
6391
6392        if text.contains("There is no wireless interface") || text.trim().is_empty() {
6393            out.push_str("No wireless interface detected on this machine.\n");
6394            return Ok(out.trim_end().to_string());
6395        }
6396
6397        let fields = [
6398            ("SSID", "SSID"),
6399            ("State", "State"),
6400            ("Signal", "Signal"),
6401            ("Radio type", "Radio type"),
6402            ("Channel", "Channel"),
6403            ("Receive rate (Mbps)", "Download speed (Mbps)"),
6404            ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6405            ("Authentication", "Authentication"),
6406            ("Network type", "Network type"),
6407        ];
6408
6409        let mut any = false;
6410        for line in text.lines() {
6411            let trimmed = line.trim();
6412            for (key, label) in &fields {
6413                if trimmed.starts_with(key) && trimmed.contains(':') {
6414                    let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6415                    if !val.is_empty() {
6416                        out.push_str(&format!("  {label}: {val}\n"));
6417                        any = true;
6418                    }
6419                }
6420            }
6421        }
6422        if !any {
6423            out.push_str("  (Wi-Fi adapter disconnected or no active connection)\n");
6424        }
6425    }
6426
6427    #[cfg(not(target_os = "windows"))]
6428    {
6429        if let Ok(o) = Command::new("nmcli")
6430            .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6431            .output()
6432        {
6433            let text = String::from_utf8_lossy(&o.stdout).to_string();
6434            let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6435            if lines.is_empty() {
6436                out.push_str("No Wi-Fi devices found.\n");
6437            } else {
6438                for l in lines {
6439                    out.push_str(&format!("  {l}\n"));
6440                }
6441            }
6442        } else if let Ok(o) = Command::new("iwconfig").output() {
6443            let text = String::from_utf8_lossy(&o.stdout).to_string();
6444            if !text.trim().is_empty() {
6445                out.push_str(text.trim());
6446                out.push('\n');
6447            }
6448        } else {
6449            out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
6450        }
6451    }
6452
6453    Ok(out.trim_end().to_string())
6454}
6455
6456// ── connections ───────────────────────────────────────────────────────────────
6457
6458fn inspect_connections(max_entries: usize) -> Result<String, String> {
6459    let mut out = String::from("Host inspection: connections\n\n");
6460    let n = max_entries.clamp(1, 25);
6461
6462    #[cfg(target_os = "windows")]
6463    {
6464        let script = format!(
6465            r#"
6466try {{
6467    $procs = @{{}}
6468    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
6469    $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
6470        Sort-Object OwningProcess
6471    "TOTAL:" + $all.Count
6472    $all | Select-Object -First {n} | ForEach-Object {{
6473        $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
6474        $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
6475    }}
6476}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6477        );
6478
6479        let output = Command::new("powershell")
6480            .args(["-NoProfile", "-Command", &script])
6481            .output()
6482            .map_err(|e| format!("connections: {e}"))?;
6483
6484        let raw = String::from_utf8_lossy(&output.stdout);
6485        let text = raw.trim();
6486
6487        if text.starts_with("ERROR:") {
6488            out.push_str(&format!("Unable to query connections: {text}\n"));
6489        } else {
6490            let mut total = 0usize;
6491            let mut rows = Vec::new();
6492            for line in text.lines() {
6493                if let Some(rest) = line.strip_prefix("TOTAL:") {
6494                    total = rest.trim().parse().unwrap_or(0);
6495                } else {
6496                    rows.push(line);
6497                }
6498            }
6499            out.push_str(&format!("Established TCP connections: {total}\n\n"));
6500            for row in &rows {
6501                let parts: Vec<&str> = row.splitn(4, '|').collect();
6502                if parts.len() == 4 {
6503                    out.push_str(&format!(
6504                        "  {:<15} (pid {:<5}) | {} → {}\n",
6505                        parts[0], parts[1], parts[2], parts[3]
6506                    ));
6507                }
6508            }
6509            if total > n {
6510                out.push_str(&format!(
6511                    "\n  ... {} more connections not shown\n",
6512                    total.saturating_sub(n)
6513                ));
6514            }
6515        }
6516    }
6517
6518    #[cfg(not(target_os = "windows"))]
6519    {
6520        if let Ok(o) = Command::new("ss")
6521            .args(["-tnp", "state", "established"])
6522            .output()
6523        {
6524            let text = String::from_utf8_lossy(&o.stdout);
6525            let lines: Vec<&str> = text
6526                .lines()
6527                .skip(1)
6528                .filter(|l| !l.trim().is_empty())
6529                .collect();
6530            out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
6531            for line in lines.iter().take(n) {
6532                out.push_str(&format!("  {}\n", line.trim()));
6533            }
6534            if lines.len() > n {
6535                out.push_str(&format!("\n  ... {} more not shown\n", lines.len() - n));
6536            }
6537        } else {
6538            out.push_str("ss not available — install iproute2\n");
6539        }
6540    }
6541
6542    Ok(out.trim_end().to_string())
6543}
6544
6545// ── vpn ───────────────────────────────────────────────────────────────────────
6546
6547fn inspect_vpn() -> Result<String, String> {
6548    let mut out = String::from("Host inspection: vpn\n\n");
6549
6550    #[cfg(target_os = "windows")]
6551    {
6552        let script = r#"
6553try {
6554    $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
6555        $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
6556        $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
6557    }
6558    if ($vpn) {
6559        foreach ($a in $vpn) {
6560            $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
6561        }
6562    } else { "NONE" }
6563} catch { "ERROR:" + $_.Exception.Message }
6564"#;
6565        let output = Command::new("powershell")
6566            .args(["-NoProfile", "-Command", script])
6567            .output()
6568            .map_err(|e| format!("vpn: {e}"))?;
6569
6570        let raw = String::from_utf8_lossy(&output.stdout);
6571        let text = raw.trim();
6572
6573        if text == "NONE" {
6574            out.push_str("No VPN adapters detected — no active VPN connection found.\n");
6575        } else if text.starts_with("ERROR:") {
6576            out.push_str(&format!("Unable to query adapters: {text}\n"));
6577        } else {
6578            out.push_str("VPN adapters:\n\n");
6579            for line in text.lines() {
6580                let parts: Vec<&str> = line.splitn(4, '|').collect();
6581                if parts.len() >= 3 {
6582                    let name = parts[0];
6583                    let desc = parts[1];
6584                    let status = parts[2];
6585                    let media = parts.get(3).unwrap_or(&"unknown");
6586                    let label = if status.trim() == "Up" {
6587                        "CONNECTED"
6588                    } else {
6589                        "disconnected"
6590                    };
6591                    out.push_str(&format!(
6592                        "  {name} [{label}]\n    {desc}\n    Status: {status} | Media: {media}\n\n"
6593                    ));
6594                }
6595            }
6596        }
6597
6598        // Windows built-in VPN connections
6599        let ras_script = r#"
6600try {
6601    $c = Get-VpnConnection -ErrorAction Stop
6602    if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
6603    else { "NO_RAS" }
6604} catch { "NO_RAS" }
6605"#;
6606        if let Ok(o) = Command::new("powershell")
6607            .args(["-NoProfile", "-Command", ras_script])
6608            .output()
6609        {
6610            let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
6611            if t != "NO_RAS" && !t.is_empty() {
6612                out.push_str("Windows VPN connections:\n");
6613                for line in t.lines() {
6614                    let parts: Vec<&str> = line.splitn(3, '|').collect();
6615                    if parts.len() >= 2 {
6616                        let name = parts[0];
6617                        let status = parts[1];
6618                        let server = parts.get(2).unwrap_or(&"");
6619                        out.push_str(&format!("  {name} → {server} [{status}]\n"));
6620                    }
6621                }
6622            }
6623        }
6624    }
6625
6626    #[cfg(not(target_os = "windows"))]
6627    {
6628        if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
6629            let text = String::from_utf8_lossy(&o.stdout);
6630            let vpn_ifaces: Vec<&str> = text
6631                .lines()
6632                .filter(|l| {
6633                    l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
6634                })
6635                .collect();
6636            if vpn_ifaces.is_empty() {
6637                out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
6638            } else {
6639                out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
6640                for l in vpn_ifaces {
6641                    out.push_str(&format!("  {}\n", l.trim()));
6642                }
6643            }
6644        }
6645    }
6646
6647    Ok(out.trim_end().to_string())
6648}
6649
6650// ── proxy ─────────────────────────────────────────────────────────────────────
6651
6652fn inspect_proxy() -> Result<String, String> {
6653    let mut out = String::from("Host inspection: proxy\n\n");
6654
6655    #[cfg(target_os = "windows")]
6656    {
6657        let script = r#"
6658$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
6659if ($ie) {
6660    "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
6661} else { "NONE" }
6662"#;
6663        if let Ok(o) = Command::new("powershell")
6664            .args(["-NoProfile", "-Command", script])
6665            .output()
6666        {
6667            let raw = String::from_utf8_lossy(&o.stdout);
6668            let text = raw.trim();
6669            if text != "NONE" && !text.is_empty() {
6670                let get = |key: &str| -> &str {
6671                    text.split('|')
6672                        .find(|s| s.starts_with(key))
6673                        .and_then(|s| s.splitn(2, ':').nth(1))
6674                        .unwrap_or("")
6675                };
6676                let enabled = get("ENABLE");
6677                let server = get("SERVER");
6678                let overrides = get("OVERRIDE");
6679                out.push_str("WinINET / IE proxy:\n");
6680                out.push_str(&format!(
6681                    "  Enabled: {}\n",
6682                    if enabled == "1" { "yes" } else { "no" }
6683                ));
6684                if !server.is_empty() && server != "None" {
6685                    out.push_str(&format!("  Proxy server: {server}\n"));
6686                }
6687                if !overrides.is_empty() && overrides != "None" {
6688                    out.push_str(&format!("  Bypass list: {overrides}\n"));
6689                }
6690                out.push('\n');
6691            }
6692        }
6693
6694        if let Ok(o) = Command::new("netsh")
6695            .args(["winhttp", "show", "proxy"])
6696            .output()
6697        {
6698            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6699            out.push_str("WinHTTP proxy:\n");
6700            for line in text.lines() {
6701                let l = line.trim();
6702                if !l.is_empty() {
6703                    out.push_str(&format!("  {l}\n"));
6704                }
6705            }
6706            out.push('\n');
6707        }
6708
6709        let mut env_found = false;
6710        for var in &[
6711            "http_proxy",
6712            "https_proxy",
6713            "HTTP_PROXY",
6714            "HTTPS_PROXY",
6715            "no_proxy",
6716            "NO_PROXY",
6717        ] {
6718            if let Ok(val) = std::env::var(var) {
6719                if !env_found {
6720                    out.push_str("Environment proxy variables:\n");
6721                    env_found = true;
6722                }
6723                out.push_str(&format!("  {var}: {val}\n"));
6724            }
6725        }
6726        if !env_found {
6727            out.push_str("No proxy environment variables set.\n");
6728        }
6729    }
6730
6731    #[cfg(not(target_os = "windows"))]
6732    {
6733        let mut found = false;
6734        for var in &[
6735            "http_proxy",
6736            "https_proxy",
6737            "HTTP_PROXY",
6738            "HTTPS_PROXY",
6739            "no_proxy",
6740            "NO_PROXY",
6741            "ALL_PROXY",
6742            "all_proxy",
6743        ] {
6744            if let Ok(val) = std::env::var(var) {
6745                if !found {
6746                    out.push_str("Proxy environment variables:\n");
6747                    found = true;
6748                }
6749                out.push_str(&format!("  {var}: {val}\n"));
6750            }
6751        }
6752        if !found {
6753            out.push_str("No proxy environment variables set.\n");
6754        }
6755        if let Ok(content) = std::fs::read_to_string("/etc/environment") {
6756            let proxy_lines: Vec<&str> = content
6757                .lines()
6758                .filter(|l| l.to_lowercase().contains("proxy"))
6759                .collect();
6760            if !proxy_lines.is_empty() {
6761                out.push_str("\nSystem proxy (/etc/environment):\n");
6762                for l in proxy_lines {
6763                    out.push_str(&format!("  {l}\n"));
6764                }
6765            }
6766        }
6767    }
6768
6769    Ok(out.trim_end().to_string())
6770}
6771
6772// ── firewall_rules ────────────────────────────────────────────────────────────
6773
6774fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
6775    let mut out = String::from("Host inspection: firewall_rules\n\n");
6776    let n = max_entries.clamp(1, 20);
6777
6778    #[cfg(target_os = "windows")]
6779    {
6780        let script = format!(
6781            r#"
6782try {{
6783    $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
6784        Where-Object {{
6785            $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
6786            $_.Owner -eq $null
6787        }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
6788    "TOTAL:" + $rules.Count
6789    $rules | ForEach-Object {{
6790        $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
6791        $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
6792        $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
6793    }}
6794}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6795        );
6796
6797        let output = Command::new("powershell")
6798            .args(["-NoProfile", "-Command", &script])
6799            .output()
6800            .map_err(|e| format!("firewall_rules: {e}"))?;
6801
6802        let raw = String::from_utf8_lossy(&output.stdout);
6803        let text = raw.trim();
6804
6805        if text.starts_with("ERROR:") {
6806            out.push_str(&format!(
6807                "Unable to query firewall rules: {}\n",
6808                text.trim_start_matches("ERROR:").trim()
6809            ));
6810            out.push_str("This query may require running as administrator.\n");
6811        } else if text.is_empty() {
6812            out.push_str("No non-default enabled firewall rules found.\n");
6813        } else {
6814            let mut total = 0usize;
6815            for line in text.lines() {
6816                if let Some(rest) = line.strip_prefix("TOTAL:") {
6817                    total = rest.trim().parse().unwrap_or(0);
6818                    out.push_str(&format!(
6819                        "Non-default enabled rules (showing up to {n}):\n\n"
6820                    ));
6821                } else {
6822                    let parts: Vec<&str> = line.splitn(4, '|').collect();
6823                    if parts.len() >= 3 {
6824                        let name = parts[0];
6825                        let dir = parts[1];
6826                        let action = parts[2];
6827                        let profile = parts.get(3).unwrap_or(&"Any");
6828                        let icon = if action == "Block" { "[!]" } else { "   " };
6829                        out.push_str(&format!(
6830                            "  {icon} [{dir}] {action}: {name} (profile: {profile})\n"
6831                        ));
6832                    }
6833                }
6834            }
6835            if total == 0 {
6836                out.push_str("No non-default enabled rules found.\n");
6837            }
6838        }
6839    }
6840
6841    #[cfg(not(target_os = "windows"))]
6842    {
6843        if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
6844            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6845            if !text.is_empty() {
6846                out.push_str(&text);
6847                out.push('\n');
6848            }
6849        } else if let Ok(o) = Command::new("iptables")
6850            .args(["-L", "-n", "--line-numbers"])
6851            .output()
6852        {
6853            let text = String::from_utf8_lossy(&o.stdout);
6854            for l in text.lines().take(n * 2) {
6855                out.push_str(&format!("  {l}\n"));
6856            }
6857        } else {
6858            out.push_str("ufw and iptables not available or insufficient permissions.\n");
6859        }
6860    }
6861
6862    Ok(out.trim_end().to_string())
6863}
6864
6865// ── traceroute ────────────────────────────────────────────────────────────────
6866
6867fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
6868    let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
6869    let hops = max_entries.clamp(5, 30);
6870
6871    #[cfg(target_os = "windows")]
6872    {
6873        let output = Command::new("tracert")
6874            .args(["-d", "-h", &hops.to_string(), host])
6875            .output()
6876            .map_err(|e| format!("tracert: {e}"))?;
6877        let raw = String::from_utf8_lossy(&output.stdout);
6878        let mut hop_count = 0usize;
6879        for line in raw.lines() {
6880            let trimmed = line.trim();
6881            if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
6882                hop_count += 1;
6883                out.push_str(&format!("  {trimmed}\n"));
6884            } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
6885                out.push_str(&format!("{trimmed}\n"));
6886            }
6887        }
6888        if hop_count == 0 {
6889            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6890        }
6891    }
6892
6893    #[cfg(not(target_os = "windows"))]
6894    {
6895        let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
6896            || std::path::Path::new("/usr/sbin/traceroute").exists()
6897        {
6898            "traceroute"
6899        } else {
6900            "tracepath"
6901        };
6902        let output = Command::new(cmd)
6903            .args(["-m", &hops.to_string(), "-n", host])
6904            .output()
6905            .map_err(|e| format!("{cmd}: {e}"))?;
6906        let raw = String::from_utf8_lossy(&output.stdout);
6907        let mut hop_count = 0usize;
6908        for line in raw.lines().take(hops + 2) {
6909            let trimmed = line.trim();
6910            if !trimmed.is_empty() {
6911                hop_count += 1;
6912                out.push_str(&format!("  {trimmed}\n"));
6913            }
6914        }
6915        if hop_count == 0 {
6916            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6917        }
6918    }
6919
6920    Ok(out.trim_end().to_string())
6921}
6922
6923// ── dns_cache ─────────────────────────────────────────────────────────────────
6924
6925fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
6926    let mut out = String::from("Host inspection: dns_cache\n\n");
6927    let n = max_entries.clamp(10, 100);
6928
6929    #[cfg(target_os = "windows")]
6930    {
6931        let output = Command::new("powershell")
6932            .args([
6933                "-NoProfile",
6934                "-Command",
6935                "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
6936            ])
6937            .output()
6938            .map_err(|e| format!("dns_cache: {e}"))?;
6939
6940        let raw = String::from_utf8_lossy(&output.stdout);
6941        let lines: Vec<&str> = raw.lines().skip(1).collect();
6942        let total = lines.len();
6943
6944        if total == 0 {
6945            out.push_str("DNS cache is empty or could not be read.\n");
6946        } else {
6947            out.push_str(&format!(
6948                "DNS cache entries (showing up to {n} of {total}):\n\n"
6949            ));
6950            let mut shown = 0usize;
6951            for line in lines.iter().take(n) {
6952                let cols: Vec<&str> = line.splitn(4, ',').collect();
6953                if cols.len() >= 3 {
6954                    let entry = cols[0].trim_matches('"');
6955                    let rtype = cols[1].trim_matches('"');
6956                    let data = cols[2].trim_matches('"');
6957                    let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
6958                    out.push_str(&format!("  {entry:<45} {rtype:<6} {data}  (TTL {ttl}s)\n"));
6959                    shown += 1;
6960                }
6961            }
6962            if total > shown {
6963                out.push_str(&format!("\n  ... and {} more entries\n", total - shown));
6964            }
6965        }
6966    }
6967
6968    #[cfg(not(target_os = "windows"))]
6969    {
6970        if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
6971            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6972            if !text.is_empty() {
6973                out.push_str("systemd-resolved statistics:\n");
6974                for line in text.lines().take(n) {
6975                    out.push_str(&format!("  {line}\n"));
6976                }
6977                out.push('\n');
6978            }
6979        }
6980        if let Ok(o) = Command::new("dscacheutil")
6981            .args(["-cachedump", "-entries", "Host"])
6982            .output()
6983        {
6984            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6985            if !text.is_empty() {
6986                out.push_str("DNS cache (macOS dscacheutil):\n");
6987                for line in text.lines().take(n) {
6988                    out.push_str(&format!("  {line}\n"));
6989                }
6990            } else {
6991                out.push_str("DNS cache is empty or not accessible on this platform.\n");
6992            }
6993        } else {
6994            out.push_str(
6995                "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
6996            );
6997        }
6998    }
6999
7000    Ok(out.trim_end().to_string())
7001}
7002
7003// ── arp ───────────────────────────────────────────────────────────────────────
7004
7005fn inspect_arp() -> Result<String, String> {
7006    let mut out = String::from("Host inspection: arp\n\n");
7007
7008    #[cfg(target_os = "windows")]
7009    {
7010        let output = Command::new("arp")
7011            .args(["-a"])
7012            .output()
7013            .map_err(|e| format!("arp: {e}"))?;
7014        let raw = String::from_utf8_lossy(&output.stdout);
7015        let mut count = 0usize;
7016        for line in raw.lines() {
7017            let t = line.trim();
7018            if t.is_empty() {
7019                continue;
7020            }
7021            out.push_str(&format!("  {t}\n"));
7022            if t.contains("dynamic") || t.contains("static") {
7023                count += 1;
7024            }
7025        }
7026        out.push_str(&format!("\nTotal entries: {count}\n"));
7027    }
7028
7029    #[cfg(not(target_os = "windows"))]
7030    {
7031        if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7032            let raw = String::from_utf8_lossy(&o.stdout);
7033            let mut count = 0usize;
7034            for line in raw.lines() {
7035                let t = line.trim();
7036                if !t.is_empty() {
7037                    out.push_str(&format!("  {t}\n"));
7038                    count += 1;
7039                }
7040            }
7041            out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
7042        } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7043            let raw = String::from_utf8_lossy(&o.stdout);
7044            let mut count = 0usize;
7045            for line in raw.lines() {
7046                let t = line.trim();
7047                if !t.is_empty() {
7048                    out.push_str(&format!("  {t}\n"));
7049                    count += 1;
7050                }
7051            }
7052            out.push_str(&format!("\nTotal entries: {count}\n"));
7053        } else {
7054            out.push_str("arp and ip neigh not available.\n");
7055        }
7056    }
7057
7058    Ok(out.trim_end().to_string())
7059}
7060
7061// ── route_table ───────────────────────────────────────────────────────────────
7062
7063fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7064    let mut out = String::from("Host inspection: route_table\n\n");
7065    let n = max_entries.clamp(10, 50);
7066
7067    #[cfg(target_os = "windows")]
7068    {
7069        let script = r#"
7070try {
7071    $routes = Get-NetRoute -ErrorAction Stop |
7072        Where-Object { $_.RouteMetric -lt 9000 } |
7073        Sort-Object RouteMetric |
7074        Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7075    "TOTAL:" + $routes.Count
7076    $routes | ForEach-Object {
7077        $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7078    }
7079} catch { "ERROR:" + $_.Exception.Message }
7080"#;
7081        let output = Command::new("powershell")
7082            .args(["-NoProfile", "-Command", script])
7083            .output()
7084            .map_err(|e| format!("route_table: {e}"))?;
7085        let raw = String::from_utf8_lossy(&output.stdout);
7086        let text = raw.trim();
7087
7088        if text.starts_with("ERROR:") {
7089            out.push_str(&format!(
7090                "Unable to read route table: {}\n",
7091                text.trim_start_matches("ERROR:").trim()
7092            ));
7093        } else {
7094            let mut shown = 0usize;
7095            for line in text.lines() {
7096                if let Some(rest) = line.strip_prefix("TOTAL:") {
7097                    let total: usize = rest.trim().parse().unwrap_or(0);
7098                    out.push_str(&format!(
7099                        "Routing table (showing up to {n} of {total} routes):\n\n"
7100                    ));
7101                    out.push_str(&format!(
7102                        "  {:<22} {:<18} {:>8}  Interface\n",
7103                        "Destination", "Next Hop", "Metric"
7104                    ));
7105                    out.push_str(&format!("  {}\n", "-".repeat(70)));
7106                } else if shown < n {
7107                    let parts: Vec<&str> = line.splitn(4, '|').collect();
7108                    if parts.len() == 4 {
7109                        let dest = parts[0];
7110                        let hop =
7111                            if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
7112                                "on-link"
7113                            } else {
7114                                parts[1]
7115                            };
7116                        let metric = parts[2];
7117                        let iface = parts[3];
7118                        out.push_str(&format!("  {dest:<22} {hop:<18} {metric:>8}  {iface}\n"));
7119                        shown += 1;
7120                    }
7121                }
7122            }
7123        }
7124    }
7125
7126    #[cfg(not(target_os = "windows"))]
7127    {
7128        if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7129            let raw = String::from_utf8_lossy(&o.stdout);
7130            let lines: Vec<&str> = raw.lines().collect();
7131            let total = lines.len();
7132            out.push_str(&format!(
7133                "Routing table (showing up to {n} of {total} routes):\n\n"
7134            ));
7135            for line in lines.iter().take(n) {
7136                out.push_str(&format!("  {line}\n"));
7137            }
7138            if total > n {
7139                out.push_str(&format!("\n  ... and {} more routes\n", total - n));
7140            }
7141        } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7142            let raw = String::from_utf8_lossy(&o.stdout);
7143            for line in raw.lines().take(n) {
7144                out.push_str(&format!("  {line}\n"));
7145            }
7146        } else {
7147            out.push_str("ip route and netstat not available.\n");
7148        }
7149    }
7150
7151    Ok(out.trim_end().to_string())
7152}
7153
7154// ── env ───────────────────────────────────────────────────────────────────────
7155
7156fn inspect_env(max_entries: usize) -> Result<String, String> {
7157    let mut out = String::from("Host inspection: env\n\n");
7158    let n = max_entries.clamp(10, 50);
7159
7160    fn looks_like_secret(name: &str) -> bool {
7161        let n = name.to_uppercase();
7162        n.contains("KEY")
7163            || n.contains("SECRET")
7164            || n.contains("TOKEN")
7165            || n.contains("PASSWORD")
7166            || n.contains("PASSWD")
7167            || n.contains("CREDENTIAL")
7168            || n.contains("AUTH")
7169            || n.contains("CERT")
7170            || n.contains("PRIVATE")
7171    }
7172
7173    let known_dev_vars: &[&str] = &[
7174        "CARGO_HOME",
7175        "RUSTUP_HOME",
7176        "GOPATH",
7177        "GOROOT",
7178        "GOBIN",
7179        "JAVA_HOME",
7180        "ANDROID_HOME",
7181        "ANDROID_SDK_ROOT",
7182        "PYTHONPATH",
7183        "PYTHONHOME",
7184        "VIRTUAL_ENV",
7185        "CONDA_DEFAULT_ENV",
7186        "CONDA_PREFIX",
7187        "NODE_PATH",
7188        "NVM_DIR",
7189        "NVM_BIN",
7190        "PNPM_HOME",
7191        "DENO_INSTALL",
7192        "DENO_DIR",
7193        "DOTNET_ROOT",
7194        "NUGET_PACKAGES",
7195        "CMAKE_HOME",
7196        "VCPKG_ROOT",
7197        "AWS_PROFILE",
7198        "AWS_REGION",
7199        "AWS_DEFAULT_REGION",
7200        "GCP_PROJECT",
7201        "GOOGLE_CLOUD_PROJECT",
7202        "GOOGLE_APPLICATION_CREDENTIALS",
7203        "AZURE_SUBSCRIPTION_ID",
7204        "DATABASE_URL",
7205        "REDIS_URL",
7206        "MONGO_URI",
7207        "EDITOR",
7208        "VISUAL",
7209        "SHELL",
7210        "TERM",
7211        "XDG_CONFIG_HOME",
7212        "XDG_DATA_HOME",
7213        "XDG_CACHE_HOME",
7214        "HOME",
7215        "USERPROFILE",
7216        "APPDATA",
7217        "LOCALAPPDATA",
7218        "TEMP",
7219        "TMP",
7220        "COMPUTERNAME",
7221        "USERNAME",
7222        "USERDOMAIN",
7223        "PROCESSOR_ARCHITECTURE",
7224        "NUMBER_OF_PROCESSORS",
7225        "OS",
7226        "HOMEDRIVE",
7227        "HOMEPATH",
7228        "HTTP_PROXY",
7229        "HTTPS_PROXY",
7230        "NO_PROXY",
7231        "ALL_PROXY",
7232        "http_proxy",
7233        "https_proxy",
7234        "no_proxy",
7235        "DOCKER_HOST",
7236        "DOCKER_BUILDKIT",
7237        "COMPOSE_PROJECT_NAME",
7238        "KUBECONFIG",
7239        "KUBE_CONTEXT",
7240        "CI",
7241        "GITHUB_ACTIONS",
7242        "GITLAB_CI",
7243        "LMSTUDIO_HOME",
7244        "HEMATITE_URL",
7245    ];
7246
7247    let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7248    all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7249    let total = all_vars.len();
7250
7251    let mut dev_found: Vec<String> = Vec::new();
7252    let mut secret_found: Vec<String> = Vec::new();
7253
7254    for (k, v) in &all_vars {
7255        if k == "PATH" {
7256            continue;
7257        }
7258        if looks_like_secret(k) {
7259            secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7260        } else {
7261            let k_upper = k.to_uppercase();
7262            let is_known = known_dev_vars
7263                .iter()
7264                .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7265            if is_known {
7266                let display = if v.len() > 120 {
7267                    format!("{k} = {}…", &v[..117])
7268                } else {
7269                    format!("{k} = {v}")
7270                };
7271                dev_found.push(display);
7272            }
7273        }
7274    }
7275
7276    out.push_str(&format!("Total environment variables: {total}\n\n"));
7277
7278    if let Ok(p) = std::env::var("PATH") {
7279        let sep = if cfg!(target_os = "windows") {
7280            ';'
7281        } else {
7282            ':'
7283        };
7284        let count = p.split(sep).count();
7285        out.push_str(&format!(
7286            "PATH: {count} entries (use topic=path for full audit)\n\n"
7287        ));
7288    }
7289
7290    if !secret_found.is_empty() {
7291        out.push_str(&format!(
7292            "=== Secret/credential variables ({} detected, values hidden) ===\n",
7293            secret_found.len()
7294        ));
7295        for s in secret_found.iter().take(n) {
7296            out.push_str(&format!("  {s}\n"));
7297        }
7298        out.push('\n');
7299    }
7300
7301    if !dev_found.is_empty() {
7302        out.push_str(&format!(
7303            "=== Developer & tool variables ({}) ===\n",
7304            dev_found.len()
7305        ));
7306        for d in dev_found.iter().take(n) {
7307            out.push_str(&format!("  {d}\n"));
7308        }
7309        out.push('\n');
7310    }
7311
7312    let other_count = all_vars
7313        .iter()
7314        .filter(|(k, _)| {
7315            k != "PATH"
7316                && !looks_like_secret(k)
7317                && !known_dev_vars
7318                    .iter()
7319                    .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7320        })
7321        .count();
7322    if other_count > 0 {
7323        out.push_str(&format!(
7324            "Other variables: {other_count} (use 'env' in shell to see all)\n"
7325        ));
7326    }
7327
7328    Ok(out.trim_end().to_string())
7329}
7330
7331// ── hosts_file ────────────────────────────────────────────────────────────────
7332
7333fn inspect_hosts_file() -> Result<String, String> {
7334    let mut out = String::from("Host inspection: hosts_file\n\n");
7335
7336    let hosts_path = if cfg!(target_os = "windows") {
7337        std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7338    } else {
7339        std::path::PathBuf::from("/etc/hosts")
7340    };
7341
7342    out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7343
7344    match fs::read_to_string(&hosts_path) {
7345        Ok(content) => {
7346            let mut active_entries: Vec<String> = Vec::new();
7347            let mut comment_lines = 0usize;
7348            let mut blank_lines = 0usize;
7349
7350            for line in content.lines() {
7351                let t = line.trim();
7352                if t.is_empty() {
7353                    blank_lines += 1;
7354                } else if t.starts_with('#') {
7355                    comment_lines += 1;
7356                } else {
7357                    active_entries.push(line.to_string());
7358                }
7359            }
7360
7361            out.push_str(&format!(
7362                "Active entries: {}  |  Comment lines: {}  |  Blank lines: {}\n\n",
7363                active_entries.len(),
7364                comment_lines,
7365                blank_lines
7366            ));
7367
7368            if active_entries.is_empty() {
7369                out.push_str(
7370                    "No active host entries (file contains only comments/blanks — standard default state).\n",
7371                );
7372            } else {
7373                out.push_str("=== Active entries ===\n");
7374                for entry in &active_entries {
7375                    out.push_str(&format!("  {entry}\n"));
7376                }
7377                out.push('\n');
7378
7379                let custom: Vec<&String> = active_entries
7380                    .iter()
7381                    .filter(|e| {
7382                        let t = e.trim_start();
7383                        !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7384                    })
7385                    .collect();
7386                if !custom.is_empty() {
7387                    out.push_str(&format!(
7388                        "[!] Custom (non-loopback) entries: {}\n",
7389                        custom.len()
7390                    ));
7391                    for e in &custom {
7392                        out.push_str(&format!("  {e}\n"));
7393                    }
7394                } else {
7395                    out.push_str("All active entries are standard loopback or block entries.\n");
7396                }
7397            }
7398
7399            out.push_str("\n=== Full file ===\n");
7400            for line in content.lines() {
7401                out.push_str(&format!("  {line}\n"));
7402            }
7403        }
7404        Err(e) => {
7405            out.push_str(&format!("Could not read hosts file: {e}\n"));
7406            if cfg!(target_os = "windows") {
7407                out.push_str(
7408                    "On Windows, run Hematite as Administrator if permission is denied.\n",
7409                );
7410            }
7411        }
7412    }
7413
7414    Ok(out.trim_end().to_string())
7415}
7416
7417// ── docker ────────────────────────────────────────────────────────────────────
7418
7419struct AuditFinding {
7420    finding: String,
7421    impact: String,
7422    fix: String,
7423}
7424
7425#[cfg(target_os = "windows")]
7426#[derive(Debug, Clone)]
7427struct WindowsPnpDevice {
7428    name: String,
7429    status: String,
7430    problem: Option<u64>,
7431    class_name: Option<String>,
7432    instance_id: Option<String>,
7433}
7434
7435#[cfg(target_os = "windows")]
7436#[derive(Debug, Clone)]
7437struct WindowsSoundDevice {
7438    name: String,
7439    status: String,
7440    manufacturer: Option<String>,
7441}
7442
7443struct DockerMountAudit {
7444    mount_type: String,
7445    source: Option<String>,
7446    destination: String,
7447    name: Option<String>,
7448    read_write: Option<bool>,
7449    driver: Option<String>,
7450    exists_on_host: Option<bool>,
7451}
7452
7453struct DockerContainerAudit {
7454    name: String,
7455    image: String,
7456    status: String,
7457    mounts: Vec<DockerMountAudit>,
7458}
7459
7460struct DockerVolumeAudit {
7461    name: String,
7462    driver: String,
7463    mountpoint: Option<String>,
7464    scope: Option<String>,
7465}
7466
7467#[cfg(target_os = "windows")]
7468struct WslDistroAudit {
7469    name: String,
7470    state: String,
7471    version: String,
7472}
7473
7474#[cfg(target_os = "windows")]
7475struct WslRootUsage {
7476    total_kb: u64,
7477    used_kb: u64,
7478    avail_kb: u64,
7479    use_percent: String,
7480    mnt_c_present: Option<bool>,
7481}
7482
7483fn docker_engine_version() -> Result<String, String> {
7484    let version_output = Command::new("docker")
7485        .args(["version", "--format", "{{.Server.Version}}"])
7486        .output();
7487
7488    match version_output {
7489        Err(_) => Err(
7490            "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
7491        ),
7492        Ok(o) if !o.status.success() => {
7493            let stderr = String::from_utf8_lossy(&o.stderr);
7494            if stderr.contains("cannot connect")
7495                || stderr.contains("Is the docker daemon running")
7496                || stderr.contains("pipe")
7497                || stderr.contains("socket")
7498            {
7499                Err(
7500                    "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
7501                )
7502            } else {
7503                Err(format!("Docker: error - {}", stderr.trim()))
7504            }
7505        }
7506        Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
7507    }
7508}
7509
7510fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
7511    let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
7512        return Vec::new();
7513    };
7514    let Value::Array(entries) = value else {
7515        return Vec::new();
7516    };
7517
7518    let mut mounts = Vec::new();
7519    for entry in entries {
7520        let mount_type = entry
7521            .get("Type")
7522            .and_then(|v| v.as_str())
7523            .unwrap_or("unknown")
7524            .to_string();
7525        let source = entry
7526            .get("Source")
7527            .and_then(|v| v.as_str())
7528            .map(|v| v.to_string());
7529        let destination = entry
7530            .get("Destination")
7531            .and_then(|v| v.as_str())
7532            .unwrap_or("?")
7533            .to_string();
7534        let name = entry
7535            .get("Name")
7536            .and_then(|v| v.as_str())
7537            .map(|v| v.to_string());
7538        let read_write = entry.get("RW").and_then(|v| v.as_bool());
7539        let driver = entry
7540            .get("Driver")
7541            .and_then(|v| v.as_str())
7542            .map(|v| v.to_string());
7543        let exists_on_host = if mount_type == "bind" {
7544            source.as_deref().map(|path| Path::new(path).exists())
7545        } else {
7546            None
7547        };
7548        mounts.push(DockerMountAudit {
7549            mount_type,
7550            source,
7551            destination,
7552            name,
7553            read_write,
7554            driver,
7555            exists_on_host,
7556        });
7557    }
7558
7559    mounts
7560}
7561
7562fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
7563    let mut audit = DockerVolumeAudit {
7564        name: name.to_string(),
7565        driver: "unknown".to_string(),
7566        mountpoint: None,
7567        scope: None,
7568    };
7569
7570    if let Ok(output) = Command::new("docker")
7571        .args(["volume", "inspect", name, "--format", "{{json .}}"])
7572        .output()
7573    {
7574        if output.status.success() {
7575            if let Ok(value) =
7576                serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
7577            {
7578                audit.driver = value
7579                    .get("Driver")
7580                    .and_then(|v| v.as_str())
7581                    .unwrap_or("unknown")
7582                    .to_string();
7583                audit.mountpoint = value
7584                    .get("Mountpoint")
7585                    .and_then(|v| v.as_str())
7586                    .map(|v| v.to_string());
7587                audit.scope = value
7588                    .get("Scope")
7589                    .and_then(|v| v.as_str())
7590                    .map(|v| v.to_string());
7591            }
7592        }
7593    }
7594
7595    audit
7596}
7597
7598#[cfg(target_os = "windows")]
7599fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
7600    let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
7601    for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
7602        let path = local_app_data
7603            .join("Docker")
7604            .join("wsl")
7605            .join("disk")
7606            .join(file_name);
7607        if let Ok(metadata) = fs::metadata(&path) {
7608            return Some((path, metadata.len()));
7609        }
7610    }
7611    None
7612}
7613
7614#[cfg(target_os = "windows")]
7615fn clean_wsl_text(raw: &[u8]) -> String {
7616    String::from_utf8_lossy(raw)
7617        .chars()
7618        .filter(|c| *c != '\0')
7619        .collect()
7620}
7621
7622#[cfg(target_os = "windows")]
7623fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
7624    let mut distros = Vec::new();
7625    for line in raw.lines() {
7626        let trimmed = line.trim();
7627        if trimmed.is_empty()
7628            || trimmed.to_uppercase().starts_with("NAME")
7629            || trimmed.starts_with("---")
7630        {
7631            continue;
7632        }
7633        let normalized = trimmed.trim_start_matches('*').trim();
7634        let cols: Vec<&str> = normalized.split_whitespace().collect();
7635        if cols.len() < 3 {
7636            continue;
7637        }
7638        let version = cols[cols.len() - 1].to_string();
7639        let state = cols[cols.len() - 2].to_string();
7640        let name = cols[..cols.len() - 2].join(" ");
7641        if !name.is_empty() {
7642            distros.push(WslDistroAudit {
7643                name,
7644                state,
7645                version,
7646            });
7647        }
7648    }
7649    distros
7650}
7651
7652#[cfg(target_os = "windows")]
7653fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
7654    let output = Command::new("wsl")
7655        .args([
7656            "-d",
7657            distro_name,
7658            "--",
7659            "sh",
7660            "-lc",
7661            "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
7662        ])
7663        .output()
7664        .ok()?;
7665    if !output.status.success() {
7666        return None;
7667    }
7668
7669    let text = clean_wsl_text(&output.stdout);
7670    let mut total_kb = 0;
7671    let mut used_kb = 0;
7672    let mut avail_kb = 0;
7673    let mut use_percent = String::from("unknown");
7674    let mut mnt_c_present = None;
7675
7676    for line in text.lines() {
7677        let trimmed = line.trim();
7678        if trimmed.starts_with("__MNTC__:") {
7679            mnt_c_present = Some(trimmed.ends_with("ok"));
7680            continue;
7681        }
7682        let cols: Vec<&str> = trimmed.split_whitespace().collect();
7683        if cols.len() >= 6 {
7684            total_kb = cols[1].parse::<u64>().unwrap_or(0);
7685            used_kb = cols[2].parse::<u64>().unwrap_or(0);
7686            avail_kb = cols[3].parse::<u64>().unwrap_or(0);
7687            use_percent = cols[4].to_string();
7688        }
7689    }
7690
7691    Some(WslRootUsage {
7692        total_kb,
7693        used_kb,
7694        avail_kb,
7695        use_percent,
7696        mnt_c_present,
7697    })
7698}
7699
7700#[cfg(target_os = "windows")]
7701fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
7702    let mut vhds = Vec::new();
7703    let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
7704        return vhds;
7705    };
7706    let packages_dir = local_app_data.join("Packages");
7707    let Ok(entries) = fs::read_dir(packages_dir) else {
7708        return vhds;
7709    };
7710
7711    for entry in entries.flatten() {
7712        let path = entry.path().join("LocalState").join("ext4.vhdx");
7713        if let Ok(metadata) = fs::metadata(&path) {
7714            vhds.push((path, metadata.len()));
7715        }
7716    }
7717    vhds.sort_by(|a, b| b.1.cmp(&a.1));
7718    vhds
7719}
7720
7721fn inspect_docker(max_entries: usize) -> Result<String, String> {
7722    let mut out = String::from("Host inspection: docker\n\n");
7723    let n = max_entries.clamp(5, 25);
7724
7725    let version_output = Command::new("docker")
7726        .args(["version", "--format", "{{.Server.Version}}"])
7727        .output();
7728
7729    match version_output {
7730        Err(_) => {
7731            out.push_str("Docker: not found on PATH.\n");
7732            out.push_str(
7733                "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
7734            );
7735            return Ok(out.trim_end().to_string());
7736        }
7737        Ok(o) if !o.status.success() => {
7738            let stderr = String::from_utf8_lossy(&o.stderr);
7739            if stderr.contains("cannot connect")
7740                || stderr.contains("Is the docker daemon running")
7741                || stderr.contains("pipe")
7742                || stderr.contains("socket")
7743            {
7744                out.push_str("Docker: installed but daemon is NOT running.\n");
7745                out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
7746            } else {
7747                out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
7748            }
7749            return Ok(out.trim_end().to_string());
7750        }
7751        Ok(o) => {
7752            let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
7753            out.push_str(&format!("Docker Engine: {version}\n"));
7754        }
7755    }
7756
7757    if let Ok(o) = Command::new("docker")
7758        .args([
7759            "info",
7760            "--format",
7761            "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
7762        ])
7763        .output()
7764    {
7765        let info = String::from_utf8_lossy(&o.stdout);
7766        for line in info.lines() {
7767            let t = line.trim();
7768            if !t.is_empty() {
7769                out.push_str(&format!("  {t}\n"));
7770            }
7771        }
7772        out.push('\n');
7773    }
7774
7775    if let Ok(o) = Command::new("docker")
7776        .args([
7777            "ps",
7778            "--format",
7779            "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
7780        ])
7781        .output()
7782    {
7783        let raw = String::from_utf8_lossy(&o.stdout);
7784        let lines: Vec<&str> = raw.lines().collect();
7785        if lines.len() <= 1 {
7786            out.push_str("Running containers: none\n\n");
7787        } else {
7788            out.push_str(&format!(
7789                "=== Running containers ({}) ===\n",
7790                lines.len().saturating_sub(1)
7791            ));
7792            for line in lines.iter().take(n + 1) {
7793                out.push_str(&format!("  {line}\n"));
7794            }
7795            if lines.len() > n + 1 {
7796                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
7797            }
7798            out.push('\n');
7799        }
7800    }
7801
7802    if let Ok(o) = Command::new("docker")
7803        .args([
7804            "images",
7805            "--format",
7806            "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
7807        ])
7808        .output()
7809    {
7810        let raw = String::from_utf8_lossy(&o.stdout);
7811        let lines: Vec<&str> = raw.lines().collect();
7812        if lines.len() > 1 {
7813            out.push_str(&format!(
7814                "=== Local images ({}) ===\n",
7815                lines.len().saturating_sub(1)
7816            ));
7817            for line in lines.iter().take(n + 1) {
7818                out.push_str(&format!("  {line}\n"));
7819            }
7820            if lines.len() > n + 1 {
7821                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
7822            }
7823            out.push('\n');
7824        }
7825    }
7826
7827    if let Ok(o) = Command::new("docker")
7828        .args([
7829            "compose",
7830            "ls",
7831            "--format",
7832            "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
7833        ])
7834        .output()
7835    {
7836        let raw = String::from_utf8_lossy(&o.stdout);
7837        let lines: Vec<&str> = raw.lines().collect();
7838        if lines.len() > 1 {
7839            out.push_str(&format!(
7840                "=== Compose projects ({}) ===\n",
7841                lines.len().saturating_sub(1)
7842            ));
7843            for line in lines.iter().take(n + 1) {
7844                out.push_str(&format!("  {line}\n"));
7845            }
7846            out.push('\n');
7847        }
7848    }
7849
7850    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7851        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7852        if !ctx.is_empty() {
7853            out.push_str(&format!("Active context: {ctx}\n"));
7854        }
7855    }
7856
7857    Ok(out.trim_end().to_string())
7858}
7859
7860// ── wsl ───────────────────────────────────────────────────────────────────────
7861
7862fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
7863    let mut out = String::from("Host inspection: docker_filesystems\n\n");
7864    let n = max_entries.clamp(3, 12);
7865
7866    match docker_engine_version() {
7867        Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
7868        Err(message) => {
7869            out.push_str(&message);
7870            return Ok(out.trim_end().to_string());
7871        }
7872    }
7873
7874    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7875        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7876        if !ctx.is_empty() {
7877            out.push_str(&format!("Active context: {ctx}\n"));
7878        }
7879    }
7880    out.push('\n');
7881
7882    let mut containers = Vec::new();
7883    if let Ok(o) = Command::new("docker")
7884        .args([
7885            "ps",
7886            "-a",
7887            "--format",
7888            "{{.Names}}\t{{.Image}}\t{{.Status}}",
7889        ])
7890        .output()
7891    {
7892        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7893            let cols: Vec<&str> = line.split('\t').collect();
7894            if cols.len() < 3 {
7895                continue;
7896            }
7897            let name = cols[0].trim().to_string();
7898            if name.is_empty() {
7899                continue;
7900            }
7901            let inspect_output = Command::new("docker")
7902                .args(["inspect", &name, "--format", "{{json .Mounts}}"])
7903                .output();
7904            let mounts = match inspect_output {
7905                Ok(result) if result.status.success() => {
7906                    parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
7907                }
7908                _ => Vec::new(),
7909            };
7910            containers.push(DockerContainerAudit {
7911                name,
7912                image: cols[1].trim().to_string(),
7913                status: cols[2].trim().to_string(),
7914                mounts,
7915            });
7916        }
7917    }
7918
7919    let mut volumes = Vec::new();
7920    if let Ok(o) = Command::new("docker")
7921        .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
7922        .output()
7923    {
7924        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7925            let cols: Vec<&str> = line.split('\t').collect();
7926            let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
7927                continue;
7928            };
7929            let mut audit = inspect_docker_volume(name);
7930            if audit.driver == "unknown" {
7931                audit.driver = cols
7932                    .get(1)
7933                    .map(|v| v.trim())
7934                    .filter(|v| !v.is_empty())
7935                    .unwrap_or("unknown")
7936                    .to_string();
7937            }
7938            volumes.push(audit);
7939        }
7940    }
7941
7942    let mut findings = Vec::new();
7943    for container in &containers {
7944        for mount in &container.mounts {
7945            if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
7946                let source = mount.source.as_deref().unwrap_or("<unknown>");
7947                findings.push(AuditFinding {
7948                    finding: format!(
7949                        "Container '{}' has a bind mount whose host source is missing: {} -> {}",
7950                        container.name, source, mount.destination
7951                    ),
7952                    impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
7953                    fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
7954                });
7955            }
7956        }
7957    }
7958
7959    #[cfg(target_os = "windows")]
7960    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
7961        if size_bytes >= 20 * 1024 * 1024 * 1024 {
7962            findings.push(AuditFinding {
7963                finding: format!(
7964                    "Docker Desktop disk image is large: {} at {}",
7965                    human_bytes(size_bytes),
7966                    path.display()
7967                ),
7968                impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
7969                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(),
7970            });
7971        }
7972    }
7973
7974    out.push_str("=== Findings ===\n");
7975    if findings.is_empty() {
7976        out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
7977        out.push_str("  Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
7978        out.push_str("  Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
7979    } else {
7980        for finding in &findings {
7981            out.push_str(&format!("- Finding: {}\n", finding.finding));
7982            out.push_str(&format!("  Impact: {}\n", finding.impact));
7983            out.push_str(&format!("  Fix: {}\n", finding.fix));
7984        }
7985    }
7986
7987    out.push_str("\n=== Container mount summary ===\n");
7988    if containers.is_empty() {
7989        out.push_str("- No containers found.\n");
7990    } else {
7991        for container in &containers {
7992            out.push_str(&format!(
7993                "- {} ({}) [{}]\n",
7994                container.name, container.image, container.status
7995            ));
7996            if container.mounts.is_empty() {
7997                out.push_str("  - no mounts reported\n");
7998                continue;
7999            }
8000            for mount in &container.mounts {
8001                let mut source = mount
8002                    .name
8003                    .clone()
8004                    .or_else(|| mount.source.clone())
8005                    .unwrap_or_else(|| "<unknown>".to_string());
8006                if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
8007                    source.push_str(" [missing]");
8008                }
8009                let mut extras = Vec::new();
8010                if let Some(rw) = mount.read_write {
8011                    extras.push(if rw { "rw" } else { "ro" }.to_string());
8012                }
8013                if let Some(driver) = &mount.driver {
8014                    extras.push(format!("driver={driver}"));
8015                }
8016                let extra_suffix = if extras.is_empty() {
8017                    String::new()
8018                } else {
8019                    format!(" ({})", extras.join(", "))
8020                };
8021                out.push_str(&format!(
8022                    "  - {}: {} -> {}{}\n",
8023                    mount.mount_type, source, mount.destination, extra_suffix
8024                ));
8025            }
8026        }
8027    }
8028
8029    out.push_str("\n=== Named volumes ===\n");
8030    if volumes.is_empty() {
8031        out.push_str("- No named volumes found.\n");
8032    } else {
8033        for volume in &volumes {
8034            let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8035            if let Some(scope) = &volume.scope {
8036                detail.push_str(&format!(", scope: {scope}"));
8037            }
8038            if let Some(mountpoint) = &volume.mountpoint {
8039                detail.push_str(&format!(", mountpoint: {mountpoint}"));
8040            }
8041            out.push_str(&format!("{detail}\n"));
8042        }
8043    }
8044
8045    #[cfg(target_os = "windows")]
8046    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8047        out.push_str("\n=== Docker Desktop disk ===\n");
8048        out.push_str(&format!(
8049            "- {} at {}\n",
8050            human_bytes(size_bytes),
8051            path.display()
8052        ));
8053    }
8054
8055    Ok(out.trim_end().to_string())
8056}
8057
8058fn inspect_wsl() -> Result<String, String> {
8059    let mut out = String::from("Host inspection: wsl\n\n");
8060
8061    #[cfg(target_os = "windows")]
8062    {
8063        if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8064            let raw = String::from_utf8_lossy(&o.stdout);
8065            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8066            for line in cleaned.lines().take(4) {
8067                let t = line.trim();
8068                if !t.is_empty() {
8069                    out.push_str(&format!("  {t}\n"));
8070                }
8071            }
8072            out.push('\n');
8073        }
8074
8075        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8076        match list_output {
8077            Err(e) => {
8078                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8079                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8080            }
8081            Ok(o) if !o.status.success() => {
8082                let stderr = String::from_utf8_lossy(&o.stderr);
8083                let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8084                out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
8085                out.push_str("Run: wsl --install\n");
8086            }
8087            Ok(o) => {
8088                let raw = String::from_utf8_lossy(&o.stdout);
8089                let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8090                let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8091                let distro_lines: Vec<&str> = lines
8092                    .iter()
8093                    .filter(|l| {
8094                        let t = l.trim();
8095                        !t.is_empty()
8096                            && !t.to_uppercase().starts_with("NAME")
8097                            && !t.starts_with("---")
8098                    })
8099                    .copied()
8100                    .collect();
8101
8102                if distro_lines.is_empty() {
8103                    out.push_str("WSL: installed but no distributions found.\n");
8104                    out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8105                } else {
8106                    out.push_str("=== WSL Distributions ===\n");
8107                    for line in &lines {
8108                        out.push_str(&format!("  {}\n", line.trim()));
8109                    }
8110                    out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
8111                }
8112            }
8113        }
8114
8115        if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8116            let raw = String::from_utf8_lossy(&o.stdout);
8117            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8118            let status_lines: Vec<&str> = cleaned
8119                .lines()
8120                .filter(|l| !l.trim().is_empty())
8121                .take(8)
8122                .collect();
8123            if !status_lines.is_empty() {
8124                out.push_str("\n=== WSL status ===\n");
8125                for line in status_lines {
8126                    out.push_str(&format!("  {}\n", line.trim()));
8127                }
8128            }
8129        }
8130    }
8131
8132    #[cfg(not(target_os = "windows"))]
8133    {
8134        out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8135        out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8136    }
8137
8138    Ok(out.trim_end().to_string())
8139}
8140
8141// ── ssh ───────────────────────────────────────────────────────────────────────
8142
8143fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8144    let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8145
8146    #[cfg(target_os = "windows")]
8147    {
8148        let n = max_entries.clamp(3, 12);
8149        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8150        let distros = match list_output {
8151            Err(e) => {
8152                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8153                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8154                return Ok(out.trim_end().to_string());
8155            }
8156            Ok(o) if !o.status.success() => {
8157                let cleaned = clean_wsl_text(&o.stderr);
8158                out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
8159                out.push_str("Run: wsl --install\n");
8160                return Ok(out.trim_end().to_string());
8161            }
8162            Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8163        };
8164
8165        out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
8166
8167        let vhdx_files = collect_wsl_vhdx_files();
8168        let mut findings = Vec::new();
8169        let mut live_usage = Vec::new();
8170
8171        for distro in distros.iter().take(n) {
8172            if distro.state.eq_ignore_ascii_case("Running") {
8173                if let Some(usage) = wsl_root_usage(&distro.name) {
8174                    if let Some(false) = usage.mnt_c_present {
8175                        findings.push(AuditFinding {
8176                            finding: format!(
8177                                "Distro '{}' is running without /mnt/c available",
8178                                distro.name
8179                            ),
8180                            impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8181                            fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8182                        });
8183                    }
8184
8185                    let percent_num = usage
8186                        .use_percent
8187                        .trim_end_matches('%')
8188                        .parse::<u32>()
8189                        .unwrap_or(0);
8190                    if percent_num >= 85 {
8191                        findings.push(AuditFinding {
8192                            finding: format!(
8193                                "Distro '{}' root filesystem is {} full",
8194                                distro.name, usage.use_percent
8195                            ),
8196                            impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8197                            fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8198                        });
8199                    }
8200                    live_usage.push((distro.name.clone(), usage));
8201                }
8202            }
8203        }
8204
8205        for (path, size_bytes) in vhdx_files.iter().take(n) {
8206            if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8207                findings.push(AuditFinding {
8208                    finding: format!(
8209                        "Host-side WSL disk image is large: {} at {}",
8210                        human_bytes(*size_bytes),
8211                        path.display()
8212                    ),
8213                    impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8214                    fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8215                });
8216            }
8217        }
8218
8219        out.push_str("=== Findings ===\n");
8220        if findings.is_empty() {
8221            out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8222            out.push_str("  Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8223            out.push_str("  Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8224        } else {
8225            for finding in &findings {
8226                out.push_str(&format!("- Finding: {}\n", finding.finding));
8227                out.push_str(&format!("  Impact: {}\n", finding.impact));
8228                out.push_str(&format!("  Fix: {}\n", finding.fix));
8229            }
8230        }
8231
8232        out.push_str("\n=== Distro bridge and root usage ===\n");
8233        if distros.is_empty() {
8234            out.push_str("- No WSL distributions found.\n");
8235        } else {
8236            for distro in distros.iter().take(n) {
8237                out.push_str(&format!(
8238                    "- {} [state: {}, version: {}]\n",
8239                    distro.name, distro.state, distro.version
8240                ));
8241                if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8242                    out.push_str(&format!(
8243                        "  - rootfs: {} used / {} total ({}), free: {}\n",
8244                        human_bytes(usage.used_kb * 1024),
8245                        human_bytes(usage.total_kb * 1024),
8246                        usage.use_percent,
8247                        human_bytes(usage.avail_kb * 1024)
8248                    ));
8249                    match usage.mnt_c_present {
8250                        Some(true) => out.push_str("  - /mnt/c bridge: present\n"),
8251                        Some(false) => out.push_str("  - /mnt/c bridge: missing\n"),
8252                        None => out.push_str("  - /mnt/c bridge: unknown\n"),
8253                    }
8254                } else if distro.state.eq_ignore_ascii_case("Running") {
8255                    out.push_str("  - live rootfs check: unavailable\n");
8256                } else {
8257                    out.push_str(
8258                        "  - live rootfs check: skipped to avoid starting a stopped distro\n",
8259                    );
8260                }
8261            }
8262        }
8263
8264        out.push_str("\n=== Host-side VHDX files ===\n");
8265        if vhdx_files.is_empty() {
8266            out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8267        } else {
8268            for (path, size_bytes) in vhdx_files.iter().take(n) {
8269                out.push_str(&format!(
8270                    "- {} at {}\n",
8271                    human_bytes(*size_bytes),
8272                    path.display()
8273                ));
8274            }
8275        }
8276    }
8277
8278    #[cfg(not(target_os = "windows"))]
8279    {
8280        let _ = max_entries;
8281        out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8282        out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8283    }
8284
8285    Ok(out.trim_end().to_string())
8286}
8287
8288fn dirs_home() -> Option<PathBuf> {
8289    std::env::var("HOME")
8290        .ok()
8291        .map(PathBuf::from)
8292        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8293}
8294
8295fn inspect_ssh() -> Result<String, String> {
8296    let mut out = String::from("Host inspection: ssh\n\n");
8297
8298    if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8299        let ver = if o.stdout.is_empty() {
8300            String::from_utf8_lossy(&o.stderr).trim().to_string()
8301        } else {
8302            String::from_utf8_lossy(&o.stdout).trim().to_string()
8303        };
8304        if !ver.is_empty() {
8305            out.push_str(&format!("SSH client: {ver}\n"));
8306        }
8307    } else {
8308        out.push_str("SSH client: not found on PATH.\n");
8309    }
8310
8311    #[cfg(target_os = "windows")]
8312    {
8313        let script = r#"
8314$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8315if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8316else { "SSHD:not_installed" }
8317"#;
8318        if let Ok(o) = Command::new("powershell")
8319            .args(["-NoProfile", "-Command", script])
8320            .output()
8321        {
8322            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8323            if text.contains("not_installed") {
8324                out.push_str("SSH server (sshd): not installed\n");
8325            } else {
8326                out.push_str(&format!(
8327                    "SSH server (sshd): {}\n",
8328                    text.trim_start_matches("SSHD:")
8329                ));
8330            }
8331        }
8332    }
8333
8334    #[cfg(not(target_os = "windows"))]
8335    {
8336        if let Ok(o) = Command::new("systemctl")
8337            .args(["is-active", "sshd"])
8338            .output()
8339        {
8340            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8341            out.push_str(&format!("SSH server (sshd): {status}\n"));
8342        } else if let Ok(o) = Command::new("systemctl")
8343            .args(["is-active", "ssh"])
8344            .output()
8345        {
8346            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8347            out.push_str(&format!("SSH server (ssh): {status}\n"));
8348        }
8349    }
8350
8351    out.push('\n');
8352
8353    if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8354        if ssh_dir.exists() {
8355            out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8356
8357            let kh = ssh_dir.join("known_hosts");
8358            if kh.exists() {
8359                let count = fs::read_to_string(&kh)
8360                    .map(|c| {
8361                        c.lines()
8362                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8363                            .count()
8364                    })
8365                    .unwrap_or(0);
8366                out.push_str(&format!("  known_hosts: {count} entries\n"));
8367            } else {
8368                out.push_str("  known_hosts: not present\n");
8369            }
8370
8371            let ak = ssh_dir.join("authorized_keys");
8372            if ak.exists() {
8373                let count = fs::read_to_string(&ak)
8374                    .map(|c| {
8375                        c.lines()
8376                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8377                            .count()
8378                    })
8379                    .unwrap_or(0);
8380                out.push_str(&format!("  authorized_keys: {count} public keys\n"));
8381            } else {
8382                out.push_str("  authorized_keys: not present\n");
8383            }
8384
8385            let key_names = [
8386                "id_rsa",
8387                "id_ed25519",
8388                "id_ecdsa",
8389                "id_dsa",
8390                "id_ecdsa_sk",
8391                "id_ed25519_sk",
8392            ];
8393            let found_keys: Vec<&str> = key_names
8394                .iter()
8395                .filter(|k| ssh_dir.join(k).exists())
8396                .copied()
8397                .collect();
8398            if !found_keys.is_empty() {
8399                out.push_str(&format!("  Private keys: {}\n", found_keys.join(", ")));
8400            } else {
8401                out.push_str("  Private keys: none found\n");
8402            }
8403
8404            let config_path = ssh_dir.join("config");
8405            if config_path.exists() {
8406                out.push_str("\n=== SSH config hosts ===\n");
8407                match fs::read_to_string(&config_path) {
8408                    Ok(content) => {
8409                        let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8410                        let mut current: Option<(String, Vec<String>)> = None;
8411                        for line in content.lines() {
8412                            let t = line.trim();
8413                            if t.is_empty() || t.starts_with('#') {
8414                                continue;
8415                            }
8416                            if let Some(host) = t.strip_prefix("Host ") {
8417                                if let Some(prev) = current.take() {
8418                                    hosts.push(prev);
8419                                }
8420                                current = Some((host.trim().to_string(), Vec::new()));
8421                            } else if let Some((_, ref mut details)) = current {
8422                                let tu = t.to_uppercase();
8423                                if tu.starts_with("HOSTNAME ")
8424                                    || tu.starts_with("USER ")
8425                                    || tu.starts_with("PORT ")
8426                                    || tu.starts_with("IDENTITYFILE ")
8427                                {
8428                                    details.push(t.to_string());
8429                                }
8430                            }
8431                        }
8432                        if let Some(prev) = current {
8433                            hosts.push(prev);
8434                        }
8435
8436                        if hosts.is_empty() {
8437                            out.push_str("  No Host entries found.\n");
8438                        } else {
8439                            for (h, details) in &hosts {
8440                                if details.is_empty() {
8441                                    out.push_str(&format!("  Host {h}\n"));
8442                                } else {
8443                                    out.push_str(&format!(
8444                                        "  Host {h}  [{}]\n",
8445                                        details.join(", ")
8446                                    ));
8447                                }
8448                            }
8449                            out.push_str(&format!("\n  Total configured hosts: {}\n", hosts.len()));
8450                        }
8451                    }
8452                    Err(e) => out.push_str(&format!("  Could not read config: {e}\n")),
8453                }
8454            } else {
8455                out.push_str("  SSH config: not present\n");
8456            }
8457        } else {
8458            out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
8459        }
8460    }
8461
8462    Ok(out.trim_end().to_string())
8463}
8464
8465// ── installed_software ────────────────────────────────────────────────────────
8466
8467fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
8468    let mut out = String::from("Host inspection: installed_software\n\n");
8469    let n = max_entries.clamp(10, 50);
8470
8471    #[cfg(target_os = "windows")]
8472    {
8473        let winget_out = Command::new("winget")
8474            .args(["list", "--accept-source-agreements"])
8475            .output();
8476
8477        if let Ok(o) = winget_out {
8478            if o.status.success() {
8479                let raw = String::from_utf8_lossy(&o.stdout);
8480                let mut header_done = false;
8481                let mut packages: Vec<&str> = Vec::new();
8482                for line in raw.lines() {
8483                    let t = line.trim();
8484                    if t.starts_with("---") {
8485                        header_done = true;
8486                        continue;
8487                    }
8488                    if header_done && !t.is_empty() {
8489                        packages.push(line);
8490                    }
8491                }
8492                let total = packages.len();
8493                out.push_str(&format!(
8494                    "=== Installed software via winget ({total} packages) ===\n\n"
8495                ));
8496                for line in packages.iter().take(n) {
8497                    out.push_str(&format!("  {line}\n"));
8498                }
8499                if total > n {
8500                    out.push_str(&format!("\n  ... and {} more packages\n", total - n));
8501                }
8502                out.push_str("\nFor full list: winget list\n");
8503                return Ok(out.trim_end().to_string());
8504            }
8505        }
8506
8507        // Fallback: registry scan
8508        let script = format!(
8509            r#"
8510$apps = @()
8511$reg_paths = @(
8512    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
8513    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
8514    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
8515)
8516foreach ($p in $reg_paths) {{
8517    try {{
8518        $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
8519            Where-Object {{ $_.DisplayName }} |
8520            Select-Object DisplayName, DisplayVersion, Publisher
8521    }} catch {{}}
8522}}
8523$sorted = $apps | Sort-Object DisplayName -Unique
8524"TOTAL:" + $sorted.Count
8525$sorted | Select-Object -First {n} | ForEach-Object {{
8526    $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
8527}}
8528"#
8529        );
8530        if let Ok(o) = Command::new("powershell")
8531            .args(["-NoProfile", "-Command", &script])
8532            .output()
8533        {
8534            let raw = String::from_utf8_lossy(&o.stdout);
8535            out.push_str("=== Installed software (registry scan) ===\n");
8536            out.push_str(&format!("  {:<50} {:<18} Publisher\n", "Name", "Version"));
8537            out.push_str(&format!("  {}\n", "-".repeat(90)));
8538            for line in raw.lines() {
8539                if let Some(rest) = line.strip_prefix("TOTAL:") {
8540                    let total: usize = rest.trim().parse().unwrap_or(0);
8541                    out.push_str(&format!("  (Total: {total}, showing first {n})\n\n"));
8542                } else if !line.trim().is_empty() {
8543                    let parts: Vec<&str> = line.splitn(3, '|').collect();
8544                    let name = parts.first().map(|s| s.trim()).unwrap_or("");
8545                    let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
8546                    let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
8547                    out.push_str(&format!("  {:<50} {:<18} {pub_}\n", name, ver));
8548                }
8549            }
8550        } else {
8551            out.push_str(
8552                "Could not query installed software (winget and registry scan both failed).\n",
8553            );
8554        }
8555    }
8556
8557    #[cfg(target_os = "linux")]
8558    {
8559        let mut found = false;
8560        if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
8561            if o.status.success() {
8562                let raw = String::from_utf8_lossy(&o.stdout);
8563                let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
8564                let total = installed.len();
8565                out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
8566                for line in installed.iter().take(n) {
8567                    out.push_str(&format!("  {}\n", line.trim()));
8568                }
8569                if total > n {
8570                    out.push_str(&format!("  ... and {} more\n", total - n));
8571                }
8572                out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
8573                found = true;
8574            }
8575        }
8576        if !found {
8577            if let Ok(o) = Command::new("rpm")
8578                .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
8579                .output()
8580            {
8581                if o.status.success() {
8582                    let raw = String::from_utf8_lossy(&o.stdout);
8583                    let lines: Vec<&str> = raw.lines().collect();
8584                    let total = lines.len();
8585                    out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
8586                    for line in lines.iter().take(n) {
8587                        out.push_str(&format!("  {line}\n"));
8588                    }
8589                    if total > n {
8590                        out.push_str(&format!("  ... and {} more\n", total - n));
8591                    }
8592                    found = true;
8593                }
8594            }
8595        }
8596        if !found {
8597            if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
8598                if o.status.success() {
8599                    let raw = String::from_utf8_lossy(&o.stdout);
8600                    let lines: Vec<&str> = raw.lines().collect();
8601                    let total = lines.len();
8602                    out.push_str(&format!(
8603                        "=== Installed packages via pacman ({total}) ===\n"
8604                    ));
8605                    for line in lines.iter().take(n) {
8606                        out.push_str(&format!("  {line}\n"));
8607                    }
8608                    if total > n {
8609                        out.push_str(&format!("  ... and {} more\n", total - n));
8610                    }
8611                    found = true;
8612                }
8613            }
8614        }
8615        if !found {
8616            out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
8617        }
8618    }
8619
8620    #[cfg(target_os = "macos")]
8621    {
8622        if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
8623            if o.status.success() {
8624                let raw = String::from_utf8_lossy(&o.stdout);
8625                let lines: Vec<&str> = raw.lines().collect();
8626                let total = lines.len();
8627                out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
8628                for line in lines.iter().take(n) {
8629                    out.push_str(&format!("  {line}\n"));
8630                }
8631                if total > n {
8632                    out.push_str(&format!("  ... and {} more\n", total - n));
8633                }
8634                out.push_str("\nFor full list: brew list --versions\n");
8635            }
8636        } else {
8637            out.push_str("Homebrew not found.\n");
8638        }
8639        if let Ok(o) = Command::new("mas").args(["list"]).output() {
8640            if o.status.success() {
8641                let raw = String::from_utf8_lossy(&o.stdout);
8642                let lines: Vec<&str> = raw.lines().collect();
8643                out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
8644                for line in lines.iter().take(n) {
8645                    out.push_str(&format!("  {line}\n"));
8646                }
8647            }
8648        }
8649    }
8650
8651    Ok(out.trim_end().to_string())
8652}
8653
8654// ── git_config ────────────────────────────────────────────────────────────────
8655
8656fn inspect_git_config() -> Result<String, String> {
8657    let mut out = String::from("Host inspection: git_config\n\n");
8658
8659    if let Ok(o) = Command::new("git").args(["--version"]).output() {
8660        let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
8661        out.push_str(&format!("Git: {ver}\n\n"));
8662    } else {
8663        out.push_str("Git: not found on PATH.\n");
8664        return Ok(out.trim_end().to_string());
8665    }
8666
8667    if let Ok(o) = Command::new("git")
8668        .args(["config", "--global", "--list"])
8669        .output()
8670    {
8671        if o.status.success() {
8672            let raw = String::from_utf8_lossy(&o.stdout);
8673            let mut pairs: Vec<(String, String)> = raw
8674                .lines()
8675                .filter_map(|l| {
8676                    let mut parts = l.splitn(2, '=');
8677                    let k = parts.next()?.trim().to_string();
8678                    let v = parts.next().unwrap_or("").trim().to_string();
8679                    Some((k, v))
8680                })
8681                .collect();
8682            pairs.sort_by(|a, b| a.0.cmp(&b.0));
8683
8684            out.push_str("=== Global git config ===\n");
8685
8686            let sections: &[(&str, &[&str])] = &[
8687                ("Identity", &["user.name", "user.email", "user.signingkey"]),
8688                (
8689                    "Core",
8690                    &[
8691                        "core.editor",
8692                        "core.autocrlf",
8693                        "core.eol",
8694                        "core.ignorecase",
8695                        "core.filemode",
8696                    ],
8697                ),
8698                (
8699                    "Commit/Signing",
8700                    &[
8701                        "commit.gpgsign",
8702                        "tag.gpgsign",
8703                        "gpg.format",
8704                        "gpg.ssh.allowedsignersfile",
8705                    ],
8706                ),
8707                (
8708                    "Push/Pull",
8709                    &[
8710                        "push.default",
8711                        "push.autosetupremote",
8712                        "pull.rebase",
8713                        "pull.ff",
8714                    ],
8715                ),
8716                ("Credential", &["credential.helper"]),
8717                ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
8718            ];
8719
8720            let mut shown_keys: HashSet<String> = HashSet::new();
8721            for (section, keys) in sections {
8722                let mut section_lines: Vec<String> = Vec::new();
8723                for key in *keys {
8724                    if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
8725                        section_lines.push(format!("  {k} = {v}"));
8726                        shown_keys.insert(k.clone());
8727                    }
8728                }
8729                if !section_lines.is_empty() {
8730                    out.push_str(&format!("\n[{section}]\n"));
8731                    for line in section_lines {
8732                        out.push_str(&format!("{line}\n"));
8733                    }
8734                }
8735            }
8736
8737            let other: Vec<&(String, String)> = pairs
8738                .iter()
8739                .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
8740                .collect();
8741            if !other.is_empty() {
8742                out.push_str("\n[Other]\n");
8743                for (k, v) in other.iter().take(20) {
8744                    out.push_str(&format!("  {k} = {v}\n"));
8745                }
8746                if other.len() > 20 {
8747                    out.push_str(&format!("  ... and {} more\n", other.len() - 20));
8748                }
8749            }
8750
8751            out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
8752        } else {
8753            out.push_str("No global git config found.\n");
8754            out.push_str("Set up with:\n");
8755            out.push_str("  git config --global user.name \"Your Name\"\n");
8756            out.push_str("  git config --global user.email \"you@example.com\"\n");
8757        }
8758    }
8759
8760    if let Ok(o) = Command::new("git")
8761        .args(["config", "--local", "--list"])
8762        .output()
8763    {
8764        if o.status.success() {
8765            let raw = String::from_utf8_lossy(&o.stdout);
8766            let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8767            if !lines.is_empty() {
8768                out.push_str(&format!(
8769                    "\n=== Local repo config ({} keys) ===\n",
8770                    lines.len()
8771                ));
8772                for line in lines.iter().take(15) {
8773                    out.push_str(&format!("  {line}\n"));
8774                }
8775                if lines.len() > 15 {
8776                    out.push_str(&format!("  ... and {} more\n", lines.len() - 15));
8777                }
8778            }
8779        }
8780    }
8781
8782    if let Ok(o) = Command::new("git")
8783        .args(["config", "--global", "--get-regexp", r"alias\."])
8784        .output()
8785    {
8786        if o.status.success() {
8787            let raw = String::from_utf8_lossy(&o.stdout);
8788            let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8789            if !aliases.is_empty() {
8790                out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
8791                for a in aliases.iter().take(20) {
8792                    out.push_str(&format!("  {a}\n"));
8793                }
8794                if aliases.len() > 20 {
8795                    out.push_str(&format!("  ... and {} more\n", aliases.len() - 20));
8796                }
8797            }
8798        }
8799    }
8800
8801    Ok(out.trim_end().to_string())
8802}
8803
8804// ── databases ─────────────────────────────────────────────────────────────────
8805
8806fn inspect_databases() -> Result<String, String> {
8807    let mut out = String::from("Host inspection: databases\n\n");
8808    out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
8809
8810    struct DbEngine {
8811        name: &'static str,
8812        service_names: &'static [&'static str],
8813        default_port: u16,
8814        cli_name: &'static str,
8815        cli_version_args: &'static [&'static str],
8816    }
8817
8818    let engines: &[DbEngine] = &[
8819        DbEngine {
8820            name: "PostgreSQL",
8821            service_names: &[
8822                "postgresql",
8823                "postgresql-x64-14",
8824                "postgresql-x64-15",
8825                "postgresql-x64-16",
8826                "postgresql-x64-17",
8827            ],
8828
8829            default_port: 5432,
8830            cli_name: "psql",
8831            cli_version_args: &["--version"],
8832        },
8833        DbEngine {
8834            name: "MySQL",
8835            service_names: &["mysql", "mysql80", "mysql57"],
8836
8837            default_port: 3306,
8838            cli_name: "mysql",
8839            cli_version_args: &["--version"],
8840        },
8841        DbEngine {
8842            name: "MariaDB",
8843            service_names: &["mariadb", "mariadb.exe"],
8844
8845            default_port: 3306,
8846            cli_name: "mariadb",
8847            cli_version_args: &["--version"],
8848        },
8849        DbEngine {
8850            name: "MongoDB",
8851            service_names: &["mongodb", "mongod"],
8852
8853            default_port: 27017,
8854            cli_name: "mongod",
8855            cli_version_args: &["--version"],
8856        },
8857        DbEngine {
8858            name: "Redis",
8859            service_names: &["redis", "redis-server"],
8860
8861            default_port: 6379,
8862            cli_name: "redis-server",
8863            cli_version_args: &["--version"],
8864        },
8865        DbEngine {
8866            name: "SQL Server",
8867            service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
8868
8869            default_port: 1433,
8870            cli_name: "sqlcmd",
8871            cli_version_args: &["-?"],
8872        },
8873        DbEngine {
8874            name: "SQLite",
8875            service_names: &[], // no service — file-based
8876
8877            default_port: 0, // no port — file-based
8878            cli_name: "sqlite3",
8879            cli_version_args: &["--version"],
8880        },
8881        DbEngine {
8882            name: "CouchDB",
8883            service_names: &["couchdb", "apache-couchdb"],
8884
8885            default_port: 5984,
8886            cli_name: "couchdb",
8887            cli_version_args: &["--version"],
8888        },
8889        DbEngine {
8890            name: "Cassandra",
8891            service_names: &["cassandra"],
8892
8893            default_port: 9042,
8894            cli_name: "cqlsh",
8895            cli_version_args: &["--version"],
8896        },
8897        DbEngine {
8898            name: "Elasticsearch",
8899            service_names: &["elasticsearch-service-x64", "elasticsearch"],
8900
8901            default_port: 9200,
8902            cli_name: "elasticsearch",
8903            cli_version_args: &["--version"],
8904        },
8905    ];
8906
8907    // Helper: check if port is listening
8908    fn port_listening(port: u16) -> bool {
8909        if port == 0 {
8910            return false;
8911        }
8912        // Use netstat-style check via connecting
8913        std::net::TcpStream::connect_timeout(
8914            &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8915            std::time::Duration::from_millis(150),
8916        )
8917        .is_ok()
8918    }
8919
8920    let mut found_any = false;
8921
8922    for engine in engines {
8923        let mut status_parts: Vec<String> = Vec::new();
8924        let mut detected = false;
8925
8926        // 1. CLI version check (fastest — works cross-platform)
8927        let version = Command::new(engine.cli_name)
8928            .args(engine.cli_version_args)
8929            .output()
8930            .ok()
8931            .and_then(|o| {
8932                let combined = if o.stdout.is_empty() {
8933                    String::from_utf8_lossy(&o.stderr).trim().to_string()
8934                } else {
8935                    String::from_utf8_lossy(&o.stdout).trim().to_string()
8936                };
8937                // Take just the first line
8938                combined.lines().next().map(|l| l.trim().to_string())
8939            });
8940
8941        if let Some(ref ver) = version {
8942            if !ver.is_empty() {
8943                status_parts.push(format!("version: {ver}"));
8944                detected = true;
8945            }
8946        }
8947
8948        // 2. Port check
8949        if engine.default_port > 0 && port_listening(engine.default_port) {
8950            status_parts.push(format!("listening on :{}", engine.default_port));
8951            detected = true;
8952        } else if engine.default_port > 0 && detected {
8953            status_parts.push(format!("not listening on :{}", engine.default_port));
8954        }
8955
8956        // 3. Windows service check
8957        #[cfg(target_os = "windows")]
8958        {
8959            if !engine.service_names.is_empty() {
8960                let service_list = engine.service_names.join("','");
8961                let script = format!(
8962                    r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
8963                    service_list
8964                );
8965                if let Ok(o) = Command::new("powershell")
8966                    .args(["-NoProfile", "-Command", &script])
8967                    .output()
8968                {
8969                    let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8970                    if !text.is_empty() {
8971                        let parts: Vec<&str> = text.splitn(2, ':').collect();
8972                        let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
8973                        let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
8974                        status_parts.push(format!("service '{svc_name}': {svc_state}"));
8975                        detected = true;
8976                    }
8977                }
8978            }
8979        }
8980
8981        // 4. Linux/macOS systemctl / launchctl check
8982        #[cfg(not(target_os = "windows"))]
8983        {
8984            for svc in engine.service_names {
8985                if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
8986                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
8987                    if !state.is_empty() && state != "inactive" {
8988                        status_parts.push(format!("systemd '{svc}': {state}"));
8989                        detected = true;
8990                        break;
8991                    }
8992                }
8993            }
8994        }
8995
8996        if detected {
8997            found_any = true;
8998            let label = if engine.default_port > 0 {
8999                format!("{} (default port: {})", engine.name, engine.default_port)
9000            } else {
9001                format!("{} (file-based, no port)", engine.name)
9002            };
9003            out.push_str(&format!("[FOUND] {label}\n"));
9004            for part in &status_parts {
9005                out.push_str(&format!("  {part}\n"));
9006            }
9007            out.push('\n');
9008        }
9009    }
9010
9011    if !found_any {
9012        out.push_str("No local database engines detected.\n");
9013        out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
9014        out.push_str(
9015            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9016        );
9017    } else {
9018        out.push_str("---\n");
9019        out.push_str(
9020            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
9021        );
9022        out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
9023    }
9024
9025    Ok(out.trim_end().to_string())
9026}
9027
9028// ── user_accounts ─────────────────────────────────────────────────────────────
9029
9030fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9031    let mut out = String::from("Host inspection: user_accounts\n\n");
9032
9033    #[cfg(target_os = "windows")]
9034    {
9035        let users_out = Command::new("powershell")
9036            .args([
9037                "-NoProfile", "-NonInteractive", "-Command",
9038                "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \"  $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9039            ])
9040            .output()
9041            .ok()
9042            .and_then(|o| String::from_utf8(o.stdout).ok())
9043            .unwrap_or_default();
9044
9045        out.push_str("=== Local User Accounts ===\n");
9046        if users_out.trim().is_empty() {
9047            out.push_str("  (requires elevation or Get-LocalUser unavailable)\n");
9048        } else {
9049            for line in users_out.lines().take(max_entries) {
9050                if !line.trim().is_empty() {
9051                    out.push_str(line);
9052                    out.push('\n');
9053                }
9054            }
9055        }
9056
9057        let admins_out = Command::new("powershell")
9058            .args([
9059                "-NoProfile", "-NonInteractive", "-Command",
9060                "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \"  $($_.ObjectClass): $($_.Name)\" }",
9061            ])
9062            .output()
9063            .ok()
9064            .and_then(|o| String::from_utf8(o.stdout).ok())
9065            .unwrap_or_default();
9066
9067        out.push_str("\n=== Administrators Group Members ===\n");
9068        if admins_out.trim().is_empty() {
9069            out.push_str("  (unable to retrieve)\n");
9070        } else {
9071            out.push_str(admins_out.trim());
9072            out.push('\n');
9073        }
9074
9075        let sessions_out = Command::new("powershell")
9076            .args([
9077                "-NoProfile",
9078                "-NonInteractive",
9079                "-Command",
9080                "query user 2>$null",
9081            ])
9082            .output()
9083            .ok()
9084            .and_then(|o| String::from_utf8(o.stdout).ok())
9085            .unwrap_or_default();
9086
9087        out.push_str("\n=== Active Logon Sessions ===\n");
9088        if sessions_out.trim().is_empty() {
9089            out.push_str("  (none or requires elevation)\n");
9090        } else {
9091            for line in sessions_out.lines().take(max_entries) {
9092                if !line.trim().is_empty() {
9093                    out.push_str(&format!("  {}\n", line));
9094                }
9095            }
9096        }
9097
9098        let is_admin = Command::new("powershell")
9099            .args([
9100                "-NoProfile", "-NonInteractive", "-Command",
9101                "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9102            ])
9103            .output()
9104            .ok()
9105            .and_then(|o| String::from_utf8(o.stdout).ok())
9106            .map(|s| s.trim().to_lowercase())
9107            .unwrap_or_default();
9108
9109        out.push_str("\n=== Current Session Elevation ===\n");
9110        out.push_str(&format!(
9111            "  Running as Administrator: {}\n",
9112            if is_admin.contains("true") {
9113                "YES"
9114            } else {
9115                "no"
9116            }
9117        ));
9118    }
9119
9120    #[cfg(not(target_os = "windows"))]
9121    {
9122        let who_out = Command::new("who")
9123            .output()
9124            .ok()
9125            .and_then(|o| String::from_utf8(o.stdout).ok())
9126            .unwrap_or_default();
9127        out.push_str("=== Active Sessions ===\n");
9128        if who_out.trim().is_empty() {
9129            out.push_str("  (none)\n");
9130        } else {
9131            for line in who_out.lines().take(max_entries) {
9132                out.push_str(&format!("  {}\n", line));
9133            }
9134        }
9135        let id_out = Command::new("id")
9136            .output()
9137            .ok()
9138            .and_then(|o| String::from_utf8(o.stdout).ok())
9139            .unwrap_or_default();
9140        out.push_str(&format!("\n=== Current User ===\n  {}\n", id_out.trim()));
9141    }
9142
9143    Ok(out.trim_end().to_string())
9144}
9145
9146// ── audit_policy ──────────────────────────────────────────────────────────────
9147
9148fn inspect_audit_policy() -> Result<String, String> {
9149    let mut out = String::from("Host inspection: audit_policy\n\n");
9150
9151    #[cfg(target_os = "windows")]
9152    {
9153        let auditpol_out = Command::new("auditpol")
9154            .args(["/get", "/category:*"])
9155            .output()
9156            .ok()
9157            .and_then(|o| String::from_utf8(o.stdout).ok())
9158            .unwrap_or_default();
9159
9160        if auditpol_out.trim().is_empty()
9161            || auditpol_out.to_lowercase().contains("access is denied")
9162        {
9163            out.push_str("Audit policy requires Administrator elevation to read.\n");
9164            out.push_str(
9165                "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9166            );
9167        } else {
9168            out.push_str("=== Windows Audit Policy ===\n");
9169            let mut any_enabled = false;
9170            for line in auditpol_out.lines() {
9171                let trimmed = line.trim();
9172                if trimmed.is_empty() {
9173                    continue;
9174                }
9175                if trimmed.contains("Success") || trimmed.contains("Failure") {
9176                    out.push_str(&format!("  [ENABLED] {}\n", trimmed));
9177                    any_enabled = true;
9178                } else {
9179                    out.push_str(&format!("  {}\n", trimmed));
9180                }
9181            }
9182            if !any_enabled {
9183                out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9184                out.push_str(
9185                    "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9186                );
9187            }
9188        }
9189
9190        let evtlog = Command::new("powershell")
9191            .args([
9192                "-NoProfile", "-NonInteractive", "-Command",
9193                "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9194            ])
9195            .output()
9196            .ok()
9197            .and_then(|o| String::from_utf8(o.stdout).ok())
9198            .map(|s| s.trim().to_string())
9199            .unwrap_or_default();
9200
9201        out.push_str(&format!(
9202            "\n=== Windows Event Log Service ===\n  Status: {}\n",
9203            if evtlog.is_empty() {
9204                "unknown".to_string()
9205            } else {
9206                evtlog
9207            }
9208        ));
9209    }
9210
9211    #[cfg(not(target_os = "windows"))]
9212    {
9213        let auditd_status = Command::new("systemctl")
9214            .args(["is-active", "auditd"])
9215            .output()
9216            .ok()
9217            .and_then(|o| String::from_utf8(o.stdout).ok())
9218            .map(|s| s.trim().to_string())
9219            .unwrap_or_else(|| "not found".to_string());
9220
9221        out.push_str(&format!(
9222            "=== auditd service ===\n  Status: {}\n",
9223            auditd_status
9224        ));
9225
9226        if auditd_status == "active" {
9227            let rules = Command::new("auditctl")
9228                .args(["-l"])
9229                .output()
9230                .ok()
9231                .and_then(|o| String::from_utf8(o.stdout).ok())
9232                .unwrap_or_default();
9233            out.push_str("\n=== Active Audit Rules ===\n");
9234            if rules.trim().is_empty() || rules.contains("No rules") {
9235                out.push_str("  No rules configured.\n");
9236            } else {
9237                for line in rules.lines() {
9238                    out.push_str(&format!("  {}\n", line));
9239                }
9240            }
9241        }
9242    }
9243
9244    Ok(out.trim_end().to_string())
9245}
9246
9247// ── shares ────────────────────────────────────────────────────────────────────
9248
9249fn inspect_shares(max_entries: usize) -> Result<String, String> {
9250    let mut out = String::from("Host inspection: shares\n\n");
9251
9252    #[cfg(target_os = "windows")]
9253    {
9254        let smb_out = Command::new("powershell")
9255            .args([
9256                "-NoProfile", "-NonInteractive", "-Command",
9257                "Get-SmbShare | ForEach-Object { \"  $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9258            ])
9259            .output()
9260            .ok()
9261            .and_then(|o| String::from_utf8(o.stdout).ok())
9262            .unwrap_or_default();
9263
9264        out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9265        let smb_lines: Vec<&str> = smb_out
9266            .lines()
9267            .filter(|l| !l.trim().is_empty())
9268            .take(max_entries)
9269            .collect();
9270        if smb_lines.is_empty() {
9271            out.push_str("  No SMB shares or unable to retrieve.\n");
9272        } else {
9273            for line in &smb_lines {
9274                let name = line.trim().split('|').next().unwrap_or("").trim();
9275                if name.ends_with('$') {
9276                    out.push_str(&format!("  {}\n", line.trim()));
9277                } else {
9278                    out.push_str(&format!("  [CUSTOM] {}\n", line.trim()));
9279                }
9280            }
9281        }
9282
9283        let smb_security = Command::new("powershell")
9284            .args([
9285                "-NoProfile", "-NonInteractive", "-Command",
9286                "Get-SmbServerConfiguration | ForEach-Object { \"  SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9287            ])
9288            .output()
9289            .ok()
9290            .and_then(|o| String::from_utf8(o.stdout).ok())
9291            .unwrap_or_default();
9292
9293        out.push_str("\n=== SMB Server Security Settings ===\n");
9294        if smb_security.trim().is_empty() {
9295            out.push_str("  (unable to retrieve)\n");
9296        } else {
9297            out.push_str(smb_security.trim());
9298            out.push('\n');
9299            if smb_security.to_lowercase().contains("smb1: true") {
9300                out.push_str("  [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9301            }
9302        }
9303
9304        let drives_out = Command::new("powershell")
9305            .args([
9306                "-NoProfile", "-NonInteractive", "-Command",
9307                "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \"  $($_.Name): -> $($_.DisplayRoot)\" }",
9308            ])
9309            .output()
9310            .ok()
9311            .and_then(|o| String::from_utf8(o.stdout).ok())
9312            .unwrap_or_default();
9313
9314        out.push_str("\n=== Mapped Network Drives ===\n");
9315        if drives_out.trim().is_empty() {
9316            out.push_str("  None.\n");
9317        } else {
9318            for line in drives_out.lines().take(max_entries) {
9319                if !line.trim().is_empty() {
9320                    out.push_str(line);
9321                    out.push('\n');
9322                }
9323            }
9324        }
9325    }
9326
9327    #[cfg(not(target_os = "windows"))]
9328    {
9329        let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9330        out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9331        if smb_conf.is_empty() {
9332            out.push_str("  Not found or Samba not installed.\n");
9333        } else {
9334            for line in smb_conf.lines().take(max_entries) {
9335                out.push_str(&format!("  {}\n", line));
9336            }
9337        }
9338        let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9339        out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9340        if nfs_exports.is_empty() {
9341            out.push_str("  Not configured.\n");
9342        } else {
9343            for line in nfs_exports.lines().take(max_entries) {
9344                out.push_str(&format!("  {}\n", line));
9345            }
9346        }
9347    }
9348
9349    Ok(out.trim_end().to_string())
9350}
9351
9352// ── dns_servers ───────────────────────────────────────────────────────────────
9353
9354fn inspect_dns_servers() -> Result<String, String> {
9355    let mut out = String::from("Host inspection: dns_servers\n\n");
9356
9357    #[cfg(target_os = "windows")]
9358    {
9359        let dns_out = Command::new("powershell")
9360            .args([
9361                "-NoProfile", "-NonInteractive", "-Command",
9362                "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \"  $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9363            ])
9364            .output()
9365            .ok()
9366            .and_then(|o| String::from_utf8(o.stdout).ok())
9367            .unwrap_or_default();
9368
9369        out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9370        if dns_out.trim().is_empty() {
9371            out.push_str("  (unable to retrieve)\n");
9372        } else {
9373            for line in dns_out.lines() {
9374                if line.trim().is_empty() {
9375                    continue;
9376                }
9377                let mut annotation = "";
9378                if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9379                    annotation = "  <- Google Public DNS";
9380                } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9381                    annotation = "  <- Cloudflare DNS";
9382                } else if line.contains("9.9.9.9") {
9383                    annotation = "  <- Quad9";
9384                } else if line.contains("208.67.222") || line.contains("208.67.220") {
9385                    annotation = "  <- OpenDNS";
9386                }
9387                out.push_str(line);
9388                out.push_str(annotation);
9389                out.push('\n');
9390            }
9391        }
9392
9393        let doh_out = Command::new("powershell")
9394            .args([
9395                "-NoProfile", "-NonInteractive", "-Command",
9396                "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \"  $($_.ServerAddress): $($_.DohTemplate)\" }",
9397            ])
9398            .output()
9399            .ok()
9400            .and_then(|o| String::from_utf8(o.stdout).ok())
9401            .unwrap_or_default();
9402
9403        out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9404        if doh_out.trim().is_empty() {
9405            out.push_str("  Not configured (plain DNS).\n");
9406        } else {
9407            out.push_str(doh_out.trim());
9408            out.push('\n');
9409        }
9410
9411        let suffixes = Command::new("powershell")
9412            .args([
9413                "-NoProfile", "-NonInteractive", "-Command",
9414                "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \"  $_\" }",
9415            ])
9416            .output()
9417            .ok()
9418            .and_then(|o| String::from_utf8(o.stdout).ok())
9419            .unwrap_or_default();
9420
9421        if !suffixes.trim().is_empty() {
9422            out.push_str("\n=== DNS Search Suffix List ===\n");
9423            out.push_str(suffixes.trim());
9424            out.push('\n');
9425        }
9426    }
9427
9428    #[cfg(not(target_os = "windows"))]
9429    {
9430        let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9431        out.push_str("=== /etc/resolv.conf ===\n");
9432        if resolv.is_empty() {
9433            out.push_str("  Not found.\n");
9434        } else {
9435            for line in resolv.lines() {
9436                if !line.trim().is_empty() && !line.starts_with('#') {
9437                    out.push_str(&format!("  {}\n", line));
9438                }
9439            }
9440        }
9441        let resolved_out = Command::new("resolvectl")
9442            .args(["status", "--no-pager"])
9443            .output()
9444            .ok()
9445            .and_then(|o| String::from_utf8(o.stdout).ok())
9446            .unwrap_or_default();
9447        if !resolved_out.is_empty() {
9448            out.push_str("\n=== systemd-resolved ===\n");
9449            for line in resolved_out.lines().take(30) {
9450                out.push_str(&format!("  {}\n", line));
9451            }
9452        }
9453    }
9454
9455    Ok(out.trim_end().to_string())
9456}
9457
9458fn inspect_bitlocker() -> Result<String, String> {
9459    let mut out = String::from("Host inspection: bitlocker\n\n");
9460
9461    #[cfg(target_os = "windows")]
9462    {
9463        let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
9464        let output = Command::new("powershell")
9465            .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
9466            .output()
9467            .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
9468
9469        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9470        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
9471
9472        if !stdout.trim().is_empty() {
9473            out.push_str("=== BitLocker Volumes ===\n");
9474            for line in stdout.lines() {
9475                out.push_str(&format!("  {}\n", line));
9476            }
9477        } else if !stderr.trim().is_empty() {
9478            if stderr.contains("Access is denied") {
9479                out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
9480            } else {
9481                out.push_str(&format!(
9482                    "Error retrieving BitLocker info: {}\n",
9483                    stderr.trim()
9484                ));
9485            }
9486        } else {
9487            out.push_str("No BitLocker volumes detected or access denied.\n");
9488        }
9489    }
9490
9491    #[cfg(not(target_os = "windows"))]
9492    {
9493        out.push_str(
9494            "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
9495        );
9496        let lsblk = Command::new("lsblk")
9497            .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
9498            .output()
9499            .ok()
9500            .and_then(|o| String::from_utf8(o.stdout).ok())
9501            .unwrap_or_default();
9502        if lsblk.contains("crypto_LUKS") {
9503            out.push_str("=== LUKS Encrypted Volumes ===\n");
9504            for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
9505                out.push_str(&format!("  {}\n", line));
9506            }
9507        } else {
9508            out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
9509        }
9510    }
9511
9512    Ok(out.trim_end().to_string())
9513}
9514
9515fn inspect_rdp() -> Result<String, String> {
9516    let mut out = String::from("Host inspection: rdp\n\n");
9517
9518    #[cfg(target_os = "windows")]
9519    {
9520        let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
9521        let f_deny = Command::new("powershell")
9522            .args([
9523                "-NoProfile",
9524                "-Command",
9525                &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
9526            ])
9527            .output()
9528            .ok()
9529            .and_then(|o| String::from_utf8(o.stdout).ok())
9530            .unwrap_or_default()
9531            .trim()
9532            .to_string();
9533
9534        let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
9535        out.push_str(&format!("=== RDP Status: {} ===\n", status));
9536
9537        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"])
9538            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
9539        out.push_str(&format!(
9540            "  Port: {}\n",
9541            if port.is_empty() {
9542                "3389 (default)"
9543            } else {
9544                &port
9545            }
9546        ));
9547
9548        let nla = Command::new("powershell")
9549            .args([
9550                "-NoProfile",
9551                "-Command",
9552                &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
9553            ])
9554            .output()
9555            .ok()
9556            .and_then(|o| String::from_utf8(o.stdout).ok())
9557            .unwrap_or_default()
9558            .trim()
9559            .to_string();
9560        out.push_str(&format!(
9561            "  NLA Required: {}\n",
9562            if nla == "1" { "Yes" } else { "No" }
9563        ));
9564
9565        out.push_str("\n=== Active Sessions ===\n");
9566        let qwinsta = Command::new("qwinsta")
9567            .output()
9568            .ok()
9569            .and_then(|o| String::from_utf8(o.stdout).ok())
9570            .unwrap_or_default();
9571        if qwinsta.trim().is_empty() {
9572            out.push_str("  No active sessions listed.\n");
9573        } else {
9574            for line in qwinsta.lines() {
9575                out.push_str(&format!("  {}\n", line));
9576            }
9577        }
9578
9579        out.push_str("\n=== Firewall Rule Check ===\n");
9580        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))\" }"])
9581            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
9582        if fw.trim().is_empty() {
9583            out.push_str("  No enabled RDP firewall rules found.\n");
9584        } else {
9585            out.push_str(fw.trim_end());
9586            out.push('\n');
9587        }
9588    }
9589
9590    #[cfg(not(target_os = "windows"))]
9591    {
9592        out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
9593        let ss = Command::new("ss")
9594            .args(["-tlnp"])
9595            .output()
9596            .ok()
9597            .and_then(|o| String::from_utf8(o.stdout).ok())
9598            .unwrap_or_default();
9599        let matches: Vec<&str> = ss
9600            .lines()
9601            .filter(|l| l.contains(":3389") || l.contains(":590"))
9602            .collect();
9603        if matches.is_empty() {
9604            out.push_str("  No RDP/VNC listeners detected via 'ss'.\n");
9605        } else {
9606            for m in matches {
9607                out.push_str(&format!("  {}\n", m));
9608            }
9609        }
9610    }
9611
9612    Ok(out.trim_end().to_string())
9613}
9614
9615fn inspect_shadow_copies() -> Result<String, String> {
9616    let mut out = String::from("Host inspection: shadow_copies\n\n");
9617
9618    #[cfg(target_os = "windows")]
9619    {
9620        let output = Command::new("vssadmin")
9621            .args(["list", "shadows"])
9622            .output()
9623            .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
9624        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9625
9626        if stdout.contains("No items found") || stdout.trim().is_empty() {
9627            out.push_str("No Volume Shadow Copies found.\n");
9628        } else {
9629            out.push_str("=== Volume Shadow Copies ===\n");
9630            for line in stdout.lines().take(50) {
9631                if line.contains("Creation Time:")
9632                    || line.contains("Contents:")
9633                    || line.contains("Volume Name:")
9634                {
9635                    out.push_str(&format!("  {}\n", line.trim()));
9636                }
9637            }
9638        }
9639
9640        out.push_str("\n=== Shadow Copy Storage ===\n");
9641        let storage_out = Command::new("vssadmin")
9642            .args(["list", "shadowstorage"])
9643            .output()
9644            .ok();
9645        if let Some(o) = storage_out {
9646            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9647            for line in stdout.lines() {
9648                if line.contains("Used Shadow Copy Storage space:")
9649                    || line.contains("Max Shadow Copy Storage space:")
9650                {
9651                    out.push_str(&format!("  {}\n", line.trim()));
9652                }
9653            }
9654        }
9655    }
9656
9657    #[cfg(not(target_os = "windows"))]
9658    {
9659        out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
9660        let lvs = Command::new("lvs")
9661            .output()
9662            .ok()
9663            .and_then(|o| String::from_utf8(o.stdout).ok())
9664            .unwrap_or_default();
9665        if !lvs.is_empty() {
9666            out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
9667            out.push_str(&lvs);
9668        } else {
9669            out.push_str("No LVM volumes detected.\n");
9670        }
9671    }
9672
9673    Ok(out.trim_end().to_string())
9674}
9675
9676fn inspect_pagefile() -> Result<String, String> {
9677    let mut out = String::from("Host inspection: pagefile\n\n");
9678
9679    #[cfg(target_os = "windows")]
9680    {
9681        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)\" }";
9682        let output = Command::new("powershell")
9683            .args(["-NoProfile", "-Command", ps_cmd])
9684            .output()
9685            .ok()
9686            .and_then(|o| String::from_utf8(o.stdout).ok())
9687            .unwrap_or_default();
9688
9689        if output.trim().is_empty() {
9690            out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
9691            let managed = Command::new("powershell")
9692                .args([
9693                    "-NoProfile",
9694                    "-Command",
9695                    "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
9696                ])
9697                .output()
9698                .ok()
9699                .and_then(|o| String::from_utf8(o.stdout).ok())
9700                .unwrap_or_default()
9701                .trim()
9702                .to_string();
9703            out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
9704        } else {
9705            out.push_str("=== Page File Usage ===\n");
9706            out.push_str(&output);
9707        }
9708    }
9709
9710    #[cfg(not(target_os = "windows"))]
9711    {
9712        out.push_str("=== Swap Usage (Linux/macOS) ===\n");
9713        let swap = Command::new("swapon")
9714            .args(["--show"])
9715            .output()
9716            .ok()
9717            .and_then(|o| String::from_utf8(o.stdout).ok())
9718            .unwrap_or_default();
9719        if swap.is_empty() {
9720            let free = Command::new("free")
9721                .args(["-h"])
9722                .output()
9723                .ok()
9724                .and_then(|o| String::from_utf8(o.stdout).ok())
9725                .unwrap_or_default();
9726            out.push_str(&free);
9727        } else {
9728            out.push_str(&swap);
9729        }
9730    }
9731
9732    Ok(out.trim_end().to_string())
9733}
9734
9735fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
9736    let mut out = String::from("Host inspection: windows_features\n\n");
9737
9738    #[cfg(target_os = "windows")]
9739    {
9740        out.push_str("=== Quick Check: Notable Features ===\n");
9741        let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
9742        let output = Command::new("powershell")
9743            .args(["-NoProfile", "-Command", quick_ps])
9744            .output()
9745            .ok();
9746
9747        if let Some(o) = output {
9748            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9749            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
9750
9751            if !stdout.trim().is_empty() {
9752                for f in stdout.lines() {
9753                    out.push_str(&format!("  [ENABLED] {}\n", f));
9754                }
9755            } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
9756                out.push_str("  Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
9757            } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
9758                out.push_str(
9759                    "  No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
9760                );
9761            }
9762        }
9763
9764        out.push_str(&format!(
9765            "\n=== All Enabled Features (capped at {}) ===\n",
9766            max_entries
9767        ));
9768        let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
9769        let all_out = Command::new("powershell")
9770            .args(["-NoProfile", "-Command", &all_ps])
9771            .output()
9772            .ok();
9773        if let Some(o) = all_out {
9774            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9775            if !stdout.trim().is_empty() {
9776                out.push_str(&stdout);
9777            }
9778        }
9779    }
9780
9781    #[cfg(not(target_os = "windows"))]
9782    {
9783        let _ = max_entries;
9784        out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
9785    }
9786
9787    Ok(out.trim_end().to_string())
9788}
9789
9790fn inspect_audio(max_entries: usize) -> Result<String, String> {
9791    let mut out = String::from("Host inspection: audio\n\n");
9792
9793    #[cfg(target_os = "windows")]
9794    {
9795        let n = max_entries.clamp(5, 20);
9796        let services = collect_services().unwrap_or_default();
9797        let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
9798        let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
9799
9800        let core_services: Vec<&ServiceEntry> = services
9801            .iter()
9802            .filter(|entry| {
9803                core_service_names
9804                    .iter()
9805                    .any(|name| entry.name.eq_ignore_ascii_case(name))
9806            })
9807            .collect();
9808        let bluetooth_audio_services: Vec<&ServiceEntry> = services
9809            .iter()
9810            .filter(|entry| {
9811                bluetooth_audio_service_names
9812                    .iter()
9813                    .any(|name| entry.name.eq_ignore_ascii_case(name))
9814            })
9815            .collect();
9816
9817        let probe_script = r#"
9818$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
9819    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9820$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
9821    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9822$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
9823    Select-Object Name, Status, Manufacturer, PNPDeviceID)
9824[pscustomobject]@{
9825    Media = $media
9826    Endpoints = $endpoints
9827    SoundDevices = $sound
9828} | ConvertTo-Json -Compress -Depth 4
9829"#;
9830        let probe_raw = Command::new("powershell")
9831            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
9832            .output()
9833            .ok()
9834            .and_then(|o| String::from_utf8(o.stdout).ok())
9835            .unwrap_or_default();
9836        let probe_loaded = !probe_raw.trim().is_empty();
9837        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
9838
9839        let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
9840        let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
9841        let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
9842
9843        let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
9844            .iter()
9845            .filter(|device| !is_microphone_like_name(&device.name))
9846            .collect();
9847        let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
9848            .iter()
9849            .filter(|device| is_microphone_like_name(&device.name))
9850            .collect();
9851        let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
9852            .iter()
9853            .filter(|device| is_bluetooth_like_name(&device.name))
9854            .collect();
9855        let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
9856            .iter()
9857            .filter(|device| windows_device_has_issue(device))
9858            .collect();
9859        let media_problems: Vec<&WindowsPnpDevice> = media_devices
9860            .iter()
9861            .filter(|device| windows_device_has_issue(device))
9862            .collect();
9863        let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
9864            .iter()
9865            .filter(|device| windows_sound_device_has_issue(device))
9866            .collect();
9867
9868        let mut findings = Vec::new();
9869
9870        let stopped_core_services: Vec<&ServiceEntry> = core_services
9871            .iter()
9872            .copied()
9873            .filter(|service| !service_is_running(service))
9874            .collect();
9875        if !stopped_core_services.is_empty() {
9876            let names = stopped_core_services
9877                .iter()
9878                .map(|service| service.name.as_str())
9879                .collect::<Vec<_>>()
9880                .join(", ");
9881            findings.push(AuditFinding {
9882                finding: format!("Core audio services are not running: {names}"),
9883                impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
9884                fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
9885            });
9886        }
9887
9888        if probe_loaded
9889            && endpoints.is_empty()
9890            && media_devices.is_empty()
9891            && sound_devices.is_empty()
9892        {
9893            findings.push(AuditFinding {
9894                finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
9895                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(),
9896                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(),
9897            });
9898        }
9899
9900        if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
9901        {
9902            let mut problem_labels = Vec::new();
9903            problem_labels.extend(
9904                endpoint_problems
9905                    .iter()
9906                    .take(3)
9907                    .map(|device| device.name.clone()),
9908            );
9909            problem_labels.extend(
9910                media_problems
9911                    .iter()
9912                    .take(3)
9913                    .map(|device| device.name.clone()),
9914            );
9915            problem_labels.extend(
9916                sound_problems
9917                    .iter()
9918                    .take(3)
9919                    .map(|device| device.name.clone()),
9920            );
9921            findings.push(AuditFinding {
9922                finding: format!(
9923                    "Windows reports audio device issues for: {}",
9924                    problem_labels.join(", ")
9925                ),
9926                impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
9927                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(),
9928            });
9929        }
9930
9931        let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
9932            .iter()
9933            .copied()
9934            .filter(|service| !service_is_running(service))
9935            .collect();
9936        if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
9937            let names = stopped_bt_audio_services
9938                .iter()
9939                .map(|service| service.name.as_str())
9940                .collect::<Vec<_>>()
9941                .join(", ");
9942            findings.push(AuditFinding {
9943                finding: format!(
9944                    "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
9945                ),
9946                impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
9947                fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
9948            });
9949        }
9950
9951        out.push_str("=== Findings ===\n");
9952        if findings.is_empty() {
9953            out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
9954            out.push_str("  Impact: Playback and recording look structurally present from this inspection pass.\n");
9955            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");
9956        } else {
9957            for finding in &findings {
9958                out.push_str(&format!("- Finding: {}\n", finding.finding));
9959                out.push_str(&format!("  Impact: {}\n", finding.impact));
9960                out.push_str(&format!("  Fix: {}\n", finding.fix));
9961            }
9962        }
9963
9964        out.push_str("\n=== Audio services ===\n");
9965        if core_services.is_empty() && bluetooth_audio_services.is_empty() {
9966            out.push_str(
9967                "- No Windows audio services were retrieved from the service inventory.\n",
9968            );
9969        } else {
9970            for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
9971                out.push_str(&format!(
9972                    "- {} | Status: {} | Startup: {}\n",
9973                    service.name,
9974                    service.status,
9975                    service.startup.as_deref().unwrap_or("Unknown")
9976                ));
9977            }
9978        }
9979
9980        out.push_str("\n=== Playback and recording endpoints ===\n");
9981        if !probe_loaded {
9982            out.push_str("- Windows endpoint inventory probe returned no data.\n");
9983        } else if endpoints.is_empty() {
9984            out.push_str("- No audio endpoints detected.\n");
9985        } else {
9986            out.push_str(&format!(
9987                "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
9988                playback_endpoints.len(),
9989                recording_endpoints.len()
9990            ));
9991            for device in playback_endpoints.iter().take(n) {
9992                out.push_str(&format!(
9993                    "- [PLAYBACK] {} | Status: {}{}\n",
9994                    device.name,
9995                    device.status,
9996                    device
9997                        .problem
9998                        .filter(|problem| *problem != 0)
9999                        .map(|problem| format!(" | ProblemCode: {problem}"))
10000                        .unwrap_or_default()
10001                ));
10002            }
10003            for device in recording_endpoints.iter().take(n) {
10004                out.push_str(&format!(
10005                    "- [MIC] {} | Status: {}{}\n",
10006                    device.name,
10007                    device.status,
10008                    device
10009                        .problem
10010                        .filter(|problem| *problem != 0)
10011                        .map(|problem| format!(" | ProblemCode: {problem}"))
10012                        .unwrap_or_default()
10013                ));
10014            }
10015        }
10016
10017        out.push_str("\n=== Sound hardware devices ===\n");
10018        if sound_devices.is_empty() {
10019            out.push_str("- No Win32_SoundDevice entries were returned.\n");
10020        } else {
10021            for device in sound_devices.iter().take(n) {
10022                out.push_str(&format!(
10023                    "- {} | Status: {}{}\n",
10024                    device.name,
10025                    device.status,
10026                    device
10027                        .manufacturer
10028                        .as_deref()
10029                        .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
10030                        .unwrap_or_default()
10031                ));
10032            }
10033        }
10034
10035        out.push_str("\n=== Media-class device inventory ===\n");
10036        if media_devices.is_empty() {
10037            out.push_str("- No media-class PnP devices were returned.\n");
10038        } else {
10039            for device in media_devices.iter().take(n) {
10040                out.push_str(&format!(
10041                    "- {} | Status: {}{}\n",
10042                    device.name,
10043                    device.status,
10044                    device
10045                        .class_name
10046                        .as_deref()
10047                        .map(|class_name| format!(" | Class: {class_name}"))
10048                        .unwrap_or_default()
10049                ));
10050            }
10051        }
10052    }
10053
10054    #[cfg(not(target_os = "windows"))]
10055    {
10056        let _ = max_entries;
10057        out.push_str(
10058            "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10059        );
10060        out.push_str(
10061            "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10062        );
10063    }
10064
10065    Ok(out.trim_end().to_string())
10066}
10067
10068fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10069    let mut out = String::from("Host inspection: bluetooth\n\n");
10070
10071    #[cfg(target_os = "windows")]
10072    {
10073        let n = max_entries.clamp(5, 20);
10074        let services = collect_services().unwrap_or_default();
10075        let bluetooth_services: Vec<&ServiceEntry> = services
10076            .iter()
10077            .filter(|entry| {
10078                entry.name.eq_ignore_ascii_case("bthserv")
10079                    || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10080                    || entry.name.eq_ignore_ascii_case("BTAGService")
10081                    || entry.name.starts_with("BluetoothUserService")
10082                    || entry
10083                        .display_name
10084                        .as_deref()
10085                        .unwrap_or("")
10086                        .to_ascii_lowercase()
10087                        .contains("bluetooth")
10088            })
10089            .collect();
10090
10091        let probe_script = r#"
10092$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10093    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10094$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10095    Where-Object {
10096        $_.Class -eq 'Bluetooth' -or
10097        $_.FriendlyName -match 'Bluetooth' -or
10098        $_.InstanceId -like 'BTH*'
10099    } |
10100    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10101$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10102    Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10103    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10104[pscustomobject]@{
10105    Radios = $radios
10106    Devices = $devices
10107    AudioEndpoints = $audio
10108} | ConvertTo-Json -Compress -Depth 4
10109"#;
10110        let probe_raw = Command::new("powershell")
10111            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10112            .output()
10113            .ok()
10114            .and_then(|o| String::from_utf8(o.stdout).ok())
10115            .unwrap_or_default();
10116        let probe_loaded = !probe_raw.trim().is_empty();
10117        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10118
10119        let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10120        let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10121        let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10122        let radio_problems: Vec<&WindowsPnpDevice> = radios
10123            .iter()
10124            .filter(|device| windows_device_has_issue(device))
10125            .collect();
10126        let device_problems: Vec<&WindowsPnpDevice> = devices
10127            .iter()
10128            .filter(|device| windows_device_has_issue(device))
10129            .collect();
10130
10131        let mut findings = Vec::new();
10132
10133        if probe_loaded && radios.is_empty() {
10134            findings.push(AuditFinding {
10135                finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10136                impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10137                fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10138            });
10139        }
10140
10141        let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10142            .iter()
10143            .copied()
10144            .filter(|service| !service_is_running(service))
10145            .collect();
10146        if !stopped_bluetooth_services.is_empty() {
10147            let names = stopped_bluetooth_services
10148                .iter()
10149                .map(|service| service.name.as_str())
10150                .collect::<Vec<_>>()
10151                .join(", ");
10152            findings.push(AuditFinding {
10153                finding: format!("Bluetooth-related services are not fully running: {names}"),
10154                impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10155                fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10156            });
10157        }
10158
10159        if !radio_problems.is_empty() || !device_problems.is_empty() {
10160            let problem_labels = radio_problems
10161                .iter()
10162                .chain(device_problems.iter())
10163                .take(5)
10164                .map(|device| device.name.as_str())
10165                .collect::<Vec<_>>()
10166                .join(", ");
10167            findings.push(AuditFinding {
10168                finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10169                impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10170                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(),
10171            });
10172        }
10173
10174        if !audio_endpoints.is_empty()
10175            && bluetooth_services
10176                .iter()
10177                .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10178            && bluetooth_services
10179                .iter()
10180                .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10181                .any(|service| !service_is_running(service))
10182        {
10183            findings.push(AuditFinding {
10184                finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10185                impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10186                fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10187            });
10188        }
10189
10190        out.push_str("=== Findings ===\n");
10191        if findings.is_empty() {
10192            out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10193            out.push_str("  Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10194            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");
10195        } else {
10196            for finding in &findings {
10197                out.push_str(&format!("- Finding: {}\n", finding.finding));
10198                out.push_str(&format!("  Impact: {}\n", finding.impact));
10199                out.push_str(&format!("  Fix: {}\n", finding.fix));
10200            }
10201        }
10202
10203        out.push_str("\n=== Bluetooth services ===\n");
10204        if bluetooth_services.is_empty() {
10205            out.push_str(
10206                "- No Bluetooth-related services were retrieved from the service inventory.\n",
10207            );
10208        } else {
10209            for service in bluetooth_services.iter().take(n) {
10210                out.push_str(&format!(
10211                    "- {} | Status: {} | Startup: {}\n",
10212                    service.name,
10213                    service.status,
10214                    service.startup.as_deref().unwrap_or("Unknown")
10215                ));
10216            }
10217        }
10218
10219        out.push_str("\n=== Bluetooth radios and adapters ===\n");
10220        if !probe_loaded {
10221            out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10222        } else if radios.is_empty() {
10223            out.push_str("- No Bluetooth radios detected.\n");
10224        } else {
10225            for device in radios.iter().take(n) {
10226                out.push_str(&format!(
10227                    "- {} | Status: {}{}\n",
10228                    device.name,
10229                    device.status,
10230                    device
10231                        .problem
10232                        .filter(|problem| *problem != 0)
10233                        .map(|problem| format!(" | ProblemCode: {problem}"))
10234                        .unwrap_or_default()
10235                ));
10236            }
10237        }
10238
10239        out.push_str("\n=== Bluetooth-associated devices ===\n");
10240        if devices.is_empty() {
10241            out.push_str("- No Bluetooth-associated device nodes detected.\n");
10242        } else {
10243            for device in devices.iter().take(n) {
10244                out.push_str(&format!(
10245                    "- {} | Status: {}{}\n",
10246                    device.name,
10247                    device.status,
10248                    device
10249                        .class_name
10250                        .as_deref()
10251                        .map(|class_name| format!(" | Class: {class_name}"))
10252                        .unwrap_or_default()
10253                ));
10254            }
10255        }
10256
10257        out.push_str("\n=== Bluetooth audio endpoints ===\n");
10258        if audio_endpoints.is_empty() {
10259            out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10260        } else {
10261            for device in audio_endpoints.iter().take(n) {
10262                out.push_str(&format!(
10263                    "- {} | Status: {}{}\n",
10264                    device.name,
10265                    device.status,
10266                    device
10267                        .instance_id
10268                        .as_deref()
10269                        .map(|instance_id| format!(" | Instance: {instance_id}"))
10270                        .unwrap_or_default()
10271                ));
10272            }
10273        }
10274    }
10275
10276    #[cfg(not(target_os = "windows"))]
10277    {
10278        let _ = max_entries;
10279        out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10280        out.push_str(
10281            "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10282        );
10283    }
10284
10285    Ok(out.trim_end().to_string())
10286}
10287
10288fn inspect_printers(max_entries: usize) -> Result<String, String> {
10289    let mut out = String::from("Host inspection: printers\n\n");
10290
10291    #[cfg(target_os = "windows")]
10292    {
10293        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)])
10294            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10295        if list.trim().is_empty() {
10296            out.push_str("No printers detected.\n");
10297        } else {
10298            out.push_str("=== Installed Printers ===\n");
10299            out.push_str(&list);
10300        }
10301
10302        let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \"  [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10303            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10304        if !jobs.trim().is_empty() {
10305            out.push_str("\n=== Active Print Jobs ===\n");
10306            out.push_str(&jobs);
10307        }
10308    }
10309
10310    #[cfg(not(target_os = "windows"))]
10311    {
10312        let _ = max_entries;
10313        out.push_str("Checking LPSTAT for printers...\n");
10314        let lpstat = Command::new("lpstat")
10315            .args(["-p", "-d"])
10316            .output()
10317            .ok()
10318            .and_then(|o| String::from_utf8(o.stdout).ok())
10319            .unwrap_or_default();
10320        if lpstat.is_empty() {
10321            out.push_str("  No CUPS/LP printers found.\n");
10322        } else {
10323            out.push_str(&lpstat);
10324        }
10325    }
10326
10327    Ok(out.trim_end().to_string())
10328}
10329
10330fn inspect_winrm() -> Result<String, String> {
10331    let mut out = String::from("Host inspection: winrm\n\n");
10332
10333    #[cfg(target_os = "windows")]
10334    {
10335        let svc = Command::new("powershell")
10336            .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10337            .output()
10338            .ok()
10339            .and_then(|o| String::from_utf8(o.stdout).ok())
10340            .unwrap_or_default()
10341            .trim()
10342            .to_string();
10343        out.push_str(&format!(
10344            "WinRM Service Status: {}\n\n",
10345            if svc.is_empty() { "NOT_FOUND" } else { &svc }
10346        ));
10347
10348        out.push_str("=== WinRM Listeners ===\n");
10349        let output = Command::new("powershell")
10350            .args([
10351                "-NoProfile",
10352                "-Command",
10353                "winrm enumerate winrm/config/listener 2>$null",
10354            ])
10355            .output()
10356            .ok();
10357        if let Some(o) = output {
10358            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10359            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10360
10361            if !stdout.trim().is_empty() {
10362                for line in stdout.lines() {
10363                    if line.contains("Address =")
10364                        || line.contains("Transport =")
10365                        || line.contains("Port =")
10366                    {
10367                        out.push_str(&format!("  {}\n", line.trim()));
10368                    }
10369                }
10370            } else if stderr.contains("Access is denied") {
10371                out.push_str("  Error: Access denied to WinRM configuration.\n");
10372            } else {
10373                out.push_str("  No listeners configured.\n");
10374            }
10375        }
10376
10377        out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
10378        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))\" }"])
10379            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10380        if test_out.trim().is_empty() {
10381            out.push_str("  WinRM not responding to local WS-Man requests.\n");
10382        } else {
10383            out.push_str(&test_out);
10384        }
10385    }
10386
10387    #[cfg(not(target_os = "windows"))]
10388    {
10389        out.push_str(
10390            "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
10391        );
10392        let ss = Command::new("ss")
10393            .args(["-tln"])
10394            .output()
10395            .ok()
10396            .and_then(|o| String::from_utf8(o.stdout).ok())
10397            .unwrap_or_default();
10398        if ss.contains(":5985") || ss.contains(":5986") {
10399            out.push_str("  WinRM ports (5985/5986) are listening.\n");
10400        } else {
10401            out.push_str("  WinRM ports not detected.\n");
10402        }
10403    }
10404
10405    Ok(out.trim_end().to_string())
10406}
10407
10408fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
10409    let mut out = String::from("Host inspection: network_stats\n\n");
10410
10411    #[cfg(target_os = "windows")]
10412    {
10413        let ps_cmd = format!(
10414            "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
10415             Start-Sleep -Milliseconds 250; \
10416             $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
10417             $s2 | ForEach-Object {{ \
10418                $name = $_.Name; \
10419                $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
10420                if ($prev) {{ \
10421                    $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
10422                    $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
10423                    $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
10424                    $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
10425                    $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
10426                    $tt = [math]::Round($_.SentBytes / 1MB, 2); \
10427                    \"  $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
10428                }} \
10429             }}",
10430            max_entries
10431        );
10432        let output = Command::new("powershell")
10433            .args(["-NoProfile", "-Command", &ps_cmd])
10434            .output()
10435            .ok()
10436            .and_then(|o| String::from_utf8(o.stdout).ok())
10437            .unwrap_or_default();
10438        if output.trim().is_empty() {
10439            out.push_str("No network adapter statistics available.\n");
10440        } else {
10441            out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
10442            out.push_str(&output);
10443        }
10444
10445        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)\" } }"])
10446            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10447        if !discards.trim().is_empty() {
10448            out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
10449            out.push_str(&discards);
10450        }
10451    }
10452
10453    #[cfg(not(target_os = "windows"))]
10454    {
10455        let _ = max_entries;
10456        out.push_str("=== Network Stats (ip -s link) ===\n");
10457        let ip_s = Command::new("ip")
10458            .args(["-s", "link"])
10459            .output()
10460            .ok()
10461            .and_then(|o| String::from_utf8(o.stdout).ok())
10462            .unwrap_or_default();
10463        if ip_s.is_empty() {
10464            let netstat = Command::new("netstat")
10465                .args(["-i"])
10466                .output()
10467                .ok()
10468                .and_then(|o| String::from_utf8(o.stdout).ok())
10469                .unwrap_or_default();
10470            out.push_str(&netstat);
10471        } else {
10472            out.push_str(&ip_s);
10473        }
10474    }
10475
10476    Ok(out.trim_end().to_string())
10477}
10478
10479fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
10480    let mut out = String::from("Host inspection: udp_ports\n\n");
10481
10482    #[cfg(target_os = "windows")]
10483    {
10484        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);
10485        let output = Command::new("powershell")
10486            .args(["-NoProfile", "-Command", &ps_cmd])
10487            .output()
10488            .ok();
10489
10490        if let Some(o) = output {
10491            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10492            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10493
10494            if !stdout.trim().is_empty() {
10495                out.push_str("=== UDP Listeners (Local:Port) ===\n");
10496                for line in stdout.lines() {
10497                    let mut note = "";
10498                    if line.contains(":53 ") {
10499                        note = " [DNS]";
10500                    } else if line.contains(":67 ") || line.contains(":68 ") {
10501                        note = " [DHCP]";
10502                    } else if line.contains(":123 ") {
10503                        note = " [NTP]";
10504                    } else if line.contains(":161 ") {
10505                        note = " [SNMP]";
10506                    } else if line.contains(":1900 ") {
10507                        note = " [SSDP/UPnP]";
10508                    } else if line.contains(":5353 ") {
10509                        note = " [mDNS]";
10510                    }
10511
10512                    out.push_str(&format!("{}{}\n", line, note));
10513                }
10514            } else if stderr.contains("Access is denied") {
10515                out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
10516            } else {
10517                out.push_str("No UDP listeners detected.\n");
10518            }
10519        }
10520    }
10521
10522    #[cfg(not(target_os = "windows"))]
10523    {
10524        let ss_out = Command::new("ss")
10525            .args(["-ulnp"])
10526            .output()
10527            .ok()
10528            .and_then(|o| String::from_utf8(o.stdout).ok())
10529            .unwrap_or_default();
10530        out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
10531        if ss_out.is_empty() {
10532            let netstat_out = Command::new("netstat")
10533                .args(["-ulnp"])
10534                .output()
10535                .ok()
10536                .and_then(|o| String::from_utf8(o.stdout).ok())
10537                .unwrap_or_default();
10538            if netstat_out.is_empty() {
10539                out.push_str("  Neither 'ss' nor 'netstat' available.\n");
10540            } else {
10541                for line in netstat_out.lines().take(max_entries) {
10542                    out.push_str(&format!("  {}\n", line));
10543                }
10544            }
10545        } else {
10546            for line in ss_out.lines().take(max_entries) {
10547                out.push_str(&format!("  {}\n", line));
10548            }
10549        }
10550    }
10551
10552    Ok(out.trim_end().to_string())
10553}
10554
10555fn inspect_gpo() -> Result<String, String> {
10556    let mut out = String::from("Host inspection: gpo\n\n");
10557
10558    #[cfg(target_os = "windows")]
10559    {
10560        let output = Command::new("gpresult")
10561            .args(["/r", "/scope", "computer"])
10562            .output()
10563            .ok();
10564
10565        if let Some(o) = output {
10566            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10567            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10568
10569            if stdout.contains("Applied Group Policy Objects") {
10570                out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
10571                let mut capture = false;
10572                for line in stdout.lines() {
10573                    if line.contains("Applied Group Policy Objects") {
10574                        capture = true;
10575                    } else if capture && line.contains("The following GPOs were not applied") {
10576                        break;
10577                    }
10578                    if capture && !line.trim().is_empty() {
10579                        out.push_str(&format!("  {}\n", line.trim()));
10580                    }
10581                }
10582            } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
10583                out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
10584            } else {
10585                out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
10586            }
10587        }
10588    }
10589
10590    #[cfg(not(target_os = "windows"))]
10591    {
10592        out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
10593    }
10594
10595    Ok(out.trim_end().to_string())
10596}
10597
10598fn inspect_certificates(max_entries: usize) -> Result<String, String> {
10599    let mut out = String::from("Host inspection: certificates\n\n");
10600
10601    #[cfg(target_os = "windows")]
10602    {
10603        let ps_cmd = format!(
10604            "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
10605                $days = ($_.NotAfter - (Get-Date)).Days; \
10606                $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
10607                \"  $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
10608            }}", 
10609            max_entries
10610        );
10611        let output = Command::new("powershell")
10612            .args(["-NoProfile", "-Command", &ps_cmd])
10613            .output()
10614            .ok();
10615
10616        if let Some(o) = output {
10617            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10618            if !stdout.trim().is_empty() {
10619                out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
10620                out.push_str(&stdout);
10621            } else {
10622                out.push_str("No certificates found in the Local Machine Personal store.\n");
10623            }
10624        }
10625    }
10626
10627    #[cfg(not(target_os = "windows"))]
10628    {
10629        let _ = max_entries;
10630        out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
10631        // Check standard cert locations
10632        for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
10633            if Path::new(path).exists() {
10634                out.push_str(&format!("  Cert directory found: {}\n", path));
10635            }
10636        }
10637    }
10638
10639    Ok(out.trim_end().to_string())
10640}
10641
10642fn inspect_integrity() -> Result<String, String> {
10643    let mut out = String::from("Host inspection: integrity\n\n");
10644
10645    #[cfg(target_os = "windows")]
10646    {
10647        let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
10648        let output = Command::new("powershell")
10649            .args(["-NoProfile", "-Command", &ps_cmd])
10650            .output()
10651            .ok();
10652
10653        if let Some(o) = output {
10654            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10655            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10656                out.push_str("=== Windows Component Store Health (CBS) ===\n");
10657                let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
10658                let repair = val
10659                    .get("AutoRepairNeeded")
10660                    .and_then(|v| v.as_u64())
10661                    .unwrap_or(0);
10662
10663                out.push_str(&format!(
10664                    "  Corruption Detected: {}\n",
10665                    if corrupt != 0 {
10666                        "YES (SFC/DISM recommended)"
10667                    } else {
10668                        "No"
10669                    }
10670                ));
10671                out.push_str(&format!(
10672                    "  Auto-Repair Needed: {}\n",
10673                    if repair != 0 { "YES" } else { "No" }
10674                ));
10675
10676                if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
10677                    out.push_str(&format!("  Last Repair Attempt: (Raw code: {})\n", last));
10678                }
10679            } else {
10680                out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
10681            }
10682        }
10683
10684        if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
10685            out.push_str(
10686                "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
10687            );
10688        }
10689    }
10690
10691    #[cfg(not(target_os = "windows"))]
10692    {
10693        out.push_str("System integrity check (Linux)\n\n");
10694        let pkg_check = Command::new("rpm")
10695            .args(["-Va"])
10696            .output()
10697            .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
10698            .ok();
10699        if let Some(o) = pkg_check {
10700            out.push_str("  Package verification system active.\n");
10701            if o.status.success() {
10702                out.push_str("  No major package integrity issues detected.\n");
10703            }
10704        }
10705    }
10706
10707    Ok(out.trim_end().to_string())
10708}
10709
10710fn inspect_domain() -> Result<String, String> {
10711    let mut out = String::from("Host inspection: domain\n\n");
10712
10713    #[cfg(target_os = "windows")]
10714    {
10715        out.push_str("=== Windows Domain / Workgroup Identity ===\n");
10716        let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
10717        let output = Command::new("powershell")
10718            .args(["-NoProfile", "-Command", &ps_cmd])
10719            .output()
10720            .ok();
10721
10722        if let Some(o) = output {
10723            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10724            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10725                let part_of_domain = val
10726                    .get("PartOfDomain")
10727                    .and_then(|v| v.as_bool())
10728                    .unwrap_or(false);
10729                let domain = val
10730                    .get("Domain")
10731                    .and_then(|v| v.as_str())
10732                    .unwrap_or("Unknown");
10733                let workgroup = val
10734                    .get("Workgroup")
10735                    .and_then(|v| v.as_str())
10736                    .unwrap_or("Unknown");
10737
10738                out.push_str(&format!(
10739                    "  Join Status: {}\n",
10740                    if part_of_domain {
10741                        "DOMAIN JOINED"
10742                    } else {
10743                        "WORKGROUP"
10744                    }
10745                ));
10746                if part_of_domain {
10747                    out.push_str(&format!("  Active Directory Domain: {}\n", domain));
10748                } else {
10749                    out.push_str(&format!("  Workgroup Name: {}\n", workgroup));
10750                }
10751
10752                if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
10753                    out.push_str(&format!("  NetBIOS Name: {}\n", name));
10754                }
10755            } else {
10756                out.push_str("  Domain identity data unavailable from WMI.\n");
10757            }
10758        } else {
10759            out.push_str("  Domain identity data unavailable from WMI.\n");
10760        }
10761    }
10762
10763    #[cfg(not(target_os = "windows"))]
10764    {
10765        let domainname = Command::new("domainname")
10766            .output()
10767            .ok()
10768            .and_then(|o| String::from_utf8(o.stdout).ok())
10769            .unwrap_or_default();
10770        out.push_str("=== Linux Domain Identity ===\n");
10771        if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
10772            out.push_str(&format!("  NIS/YP Domain: {}\n", domainname.trim()));
10773        } else {
10774            out.push_str("  No NIS domain configured.\n");
10775        }
10776    }
10777
10778    Ok(out.trim_end().to_string())
10779}
10780
10781fn inspect_device_health() -> Result<String, String> {
10782    let mut out = String::from("Host inspection: device_health\n\n");
10783
10784    #[cfg(target_os = "windows")]
10785    {
10786        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)\" }";
10787        let output = Command::new("powershell")
10788            .args(["-NoProfile", "-Command", ps_cmd])
10789            .output()
10790            .ok()
10791            .and_then(|o| String::from_utf8(o.stdout).ok())
10792            .unwrap_or_default();
10793
10794        if output.trim().is_empty() {
10795            out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
10796        } else {
10797            out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
10798            out.push_str(&output);
10799            out.push_str(
10800                "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
10801            );
10802        }
10803    }
10804
10805    #[cfg(not(target_os = "windows"))]
10806    {
10807        out.push_str("Checking dmesg for hardware errors...\n");
10808        let dmesg = Command::new("dmesg")
10809            .args(["--level=err,crit,alert"])
10810            .output()
10811            .ok()
10812            .and_then(|o| String::from_utf8(o.stdout).ok())
10813            .unwrap_or_default();
10814        if dmesg.is_empty() {
10815            out.push_str("  No critical hardware errors found in dmesg.\n");
10816        } else {
10817            out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
10818        }
10819    }
10820
10821    Ok(out.trim_end().to_string())
10822}
10823
10824fn inspect_drivers(max_entries: usize) -> Result<String, String> {
10825    let mut out = String::from("Host inspection: drivers\n\n");
10826
10827    #[cfg(target_os = "windows")]
10828    {
10829        out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
10830        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);
10831        let output = Command::new("powershell")
10832            .args(["-NoProfile", "-Command", &ps_cmd])
10833            .output()
10834            .ok()
10835            .and_then(|o| String::from_utf8(o.stdout).ok())
10836            .unwrap_or_default();
10837
10838        if output.trim().is_empty() {
10839            out.push_str("  No drivers retrieved via WMI.\n");
10840        } else {
10841            out.push_str(&output);
10842        }
10843    }
10844
10845    #[cfg(not(target_os = "windows"))]
10846    {
10847        out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
10848        let lsmod = Command::new("lsmod")
10849            .output()
10850            .ok()
10851            .and_then(|o| String::from_utf8(o.stdout).ok())
10852            .unwrap_or_default();
10853        out.push_str(
10854            &lsmod
10855                .lines()
10856                .take(max_entries)
10857                .collect::<Vec<_>>()
10858                .join("\n"),
10859        );
10860    }
10861
10862    Ok(out.trim_end().to_string())
10863}
10864
10865fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
10866    let mut out = String::from("Host inspection: peripherals\n\n");
10867
10868    #[cfg(target_os = "windows")]
10869    {
10870        let _ = max_entries;
10871        out.push_str("=== USB Controllers & Hubs ===\n");
10872        let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \"  $($_.Name) ($($_.Status))\" }"])
10873            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10874        out.push_str(if usb.is_empty() {
10875            "  None detected.\n"
10876        } else {
10877            &usb
10878        });
10879
10880        out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
10881        let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \"  [KB] $($_.Name) ($($_.Status))\" }"])
10882            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10883        let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \"  [PTR] $($_.Name) ($($_.Status))\" }"])
10884            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10885        out.push_str(&kb);
10886        out.push_str(&mouse);
10887
10888        out.push_str("\n=== Connected Monitors (WMI) ===\n");
10889        let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \"  Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
10890            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10891        out.push_str(if mon.is_empty() {
10892            "  No active monitors identified via WMI.\n"
10893        } else {
10894            &mon
10895        });
10896    }
10897
10898    #[cfg(not(target_os = "windows"))]
10899    {
10900        out.push_str("=== Connected USB Devices (lsusb) ===\n");
10901        let lsusb = Command::new("lsusb")
10902            .output()
10903            .ok()
10904            .and_then(|o| String::from_utf8(o.stdout).ok())
10905            .unwrap_or_default();
10906        out.push_str(
10907            &lsusb
10908                .lines()
10909                .take(max_entries)
10910                .collect::<Vec<_>>()
10911                .join("\n"),
10912        );
10913    }
10914
10915    Ok(out.trim_end().to_string())
10916}
10917
10918fn inspect_sessions(max_entries: usize) -> Result<String, String> {
10919    let mut out = String::from("Host inspection: sessions\n\n");
10920
10921    #[cfg(target_os = "windows")]
10922    {
10923        out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
10924        let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
10925    "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
10926}"#;
10927        if let Ok(o) = Command::new("powershell")
10928            .args(["-NoProfile", "-Command", script])
10929            .output()
10930        {
10931            let text = String::from_utf8_lossy(&o.stdout);
10932            let lines: Vec<&str> = text.lines().collect();
10933            if lines.is_empty() {
10934                out.push_str("  No active logon sessions enumerated via WMI.\n");
10935            } else {
10936                for line in lines
10937                    .iter()
10938                    .take(max_entries)
10939                    .filter(|l| !l.trim().is_empty())
10940                {
10941                    let parts: Vec<&str> = line.trim().split('|').collect();
10942                    if parts.len() == 4 {
10943                        let logon_type = match parts[2] {
10944                            "2" => "Interactive",
10945                            "3" => "Network",
10946                            "4" => "Batch",
10947                            "5" => "Service",
10948                            "7" => "Unlock",
10949                            "8" => "NetworkCleartext",
10950                            "9" => "NewCredentials",
10951                            "10" => "RemoteInteractive",
10952                            "11" => "CachedInteractive",
10953                            _ => "Other",
10954                        };
10955                        out.push_str(&format!(
10956                            "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
10957                            parts[0], logon_type, parts[1], parts[3]
10958                        ));
10959                    }
10960                }
10961            }
10962        } else {
10963            out.push_str("  Active logon session data unavailable from WMI.\n");
10964        }
10965    }
10966
10967    #[cfg(not(target_os = "windows"))]
10968    {
10969        out.push_str("=== Logged-in Users (who) ===\n");
10970        let who = Command::new("who")
10971            .output()
10972            .ok()
10973            .and_then(|o| String::from_utf8(o.stdout).ok())
10974            .unwrap_or_default();
10975        out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
10976    }
10977
10978    Ok(out.trim_end().to_string())
10979}
10980
10981async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
10982    let mut out = String::from("Host inspection: disk_benchmark\n\n");
10983    let mut final_path = path;
10984
10985    if !final_path.exists() {
10986        if let Ok(current_exe) = std::env::current_exe() {
10987            out.push_str(&format!(
10988                "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
10989                final_path.display()
10990            ));
10991            final_path = current_exe;
10992        } else {
10993            return Err(format!("Target not found: {}", final_path.display()));
10994        }
10995    }
10996
10997    let target = if final_path.is_dir() {
10998        // Find a representative file to read
10999        let mut target_file = final_path.join("Cargo.toml");
11000        if !target_file.exists() {
11001            target_file = final_path.join("README.md");
11002        }
11003        if !target_file.exists() {
11004            return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
11005        }
11006        target_file
11007    } else {
11008        final_path
11009    };
11010
11011    out.push_str(&format!("Target: {}\n", target.display()));
11012    out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
11013
11014    #[cfg(target_os = "windows")]
11015    {
11016        let script = format!(
11017            r#"
11018$target = "{}"
11019if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
11020
11021$diskQueue = @()
11022$readStats = @()
11023$startTime = Get-Date
11024$duration = 5
11025
11026# Background reader job
11027$job = Start-Job -ScriptBlock {{
11028    param($t, $d)
11029    $stop = (Get-Date).AddSeconds($d)
11030    while ((Get-Date) -lt $stop) {{
11031        try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11032    }}
11033}} -ArgumentList $target, $duration
11034
11035# Metrics collector loop
11036$stopTime = (Get-Date).AddSeconds($duration)
11037while ((Get-Date) -lt $stopTime) {{
11038    $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11039    if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11040    
11041    $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11042    if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11043    
11044    Start-Sleep -Milliseconds 250
11045}}
11046
11047Stop-Job $job
11048Receive-Job $job | Out-Null
11049Remove-Job $job
11050
11051$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11052$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11053$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11054
11055"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11056"#,
11057            target.display()
11058        );
11059
11060        let output = Command::new("powershell")
11061            .args(["-NoProfile", "-Command", &script])
11062            .output()
11063            .map_err(|e| format!("Benchmark failed: {e}"))?;
11064
11065        let raw = String::from_utf8_lossy(&output.stdout);
11066        let text = raw.trim();
11067
11068        if text.starts_with("ERROR") {
11069            return Err(text.to_string());
11070        }
11071
11072        let mut lines = text.lines();
11073        if let Some(metrics_line) = lines.next() {
11074            let parts: Vec<&str> = metrics_line.split('|').collect();
11075            let mut avg_q = "unknown".to_string();
11076            let mut max_q = "unknown".to_string();
11077            let mut avg_r = "unknown".to_string();
11078
11079            for p in parts {
11080                if let Some((k, v)) = p.split_once(':') {
11081                    match k {
11082                        "AVG_Q" => avg_q = v.to_string(),
11083                        "MAX_Q" => max_q = v.to_string(),
11084                        "AVG_R" => avg_r = v.to_string(),
11085                        _ => {}
11086                    }
11087                }
11088            }
11089
11090            out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11091            out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
11092            out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
11093            out.push_str(&format!("- Disk Throughput (Avg):  {} reads/sec\n", avg_r));
11094            out.push_str("\nVerdict: ");
11095            let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11096            if q_num > 1.0 {
11097                out.push_str(
11098                    "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11099                );
11100            } else if q_num > 0.1 {
11101                out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11102            } else {
11103                out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11104            }
11105        }
11106    }
11107
11108    #[cfg(not(target_os = "windows"))]
11109    {
11110        out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11111        out.push_str("Generic disk load simulated.\n");
11112    }
11113
11114    Ok(out)
11115}
11116
11117fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11118    let mut out = String::from("Host inspection: permissions\n\n");
11119    out.push_str(&format!(
11120        "Auditing access control for: {}\n\n",
11121        path.display()
11122    ));
11123
11124    #[cfg(target_os = "windows")]
11125    {
11126        let script = format!(
11127            "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11128            path.display()
11129        );
11130        let output = Command::new("powershell")
11131            .args(["-NoProfile", "-Command", &script])
11132            .output()
11133            .map_err(|e| format!("ACL check failed: {e}"))?;
11134
11135        let text = String::from_utf8_lossy(&output.stdout);
11136        if text.trim().is_empty() {
11137            out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11138        } else {
11139            out.push_str("=== Windows NTFS Permissions ===\n");
11140            out.push_str(&text);
11141        }
11142    }
11143
11144    #[cfg(not(target_os = "windows"))]
11145    {
11146        let output = Command::new("ls")
11147            .args(["-ld", &path.to_string_lossy()])
11148            .output()
11149            .map_err(|e| format!("ls check failed: {e}"))?;
11150        out.push_str("=== Unix File Permissions ===\n");
11151        out.push_str(&String::from_utf8_lossy(&output.stdout));
11152    }
11153
11154    Ok(out.trim_end().to_string())
11155}
11156
11157fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11158    let mut out = String::from("Host inspection: login_history\n\n");
11159
11160    #[cfg(target_os = "windows")]
11161    {
11162        out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11163        out.push_str("Note: This typically requires Administrator elevation.\n\n");
11164
11165        let n = max_entries.clamp(1, 50);
11166        let script = format!(
11167            r#"try {{
11168    $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11169    $events | ForEach-Object {{
11170        $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11171        # Extract target user name from the XML/Properties if possible
11172        $user = $_.Properties[5].Value
11173        $type = $_.Properties[8].Value
11174        "[$time] User: $user | Type: $type"
11175    }}
11176}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11177        );
11178
11179        let output = Command::new("powershell")
11180            .args(["-NoProfile", "-Command", &script])
11181            .output()
11182            .map_err(|e| format!("Login history query failed: {e}"))?;
11183
11184        let text = String::from_utf8_lossy(&output.stdout);
11185        if text.starts_with("ERROR:") {
11186            out.push_str(&format!("Unable to query Security Log: {}\n", text));
11187        } else if text.trim().is_empty() {
11188            out.push_str("No recent logon events found or access denied.\n");
11189        } else {
11190            out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11191            out.push_str(&text);
11192        }
11193    }
11194
11195    #[cfg(not(target_os = "windows"))]
11196    {
11197        let output = Command::new("last")
11198            .args(["-n", &max_entries.to_string()])
11199            .output()
11200            .map_err(|e| format!("last command failed: {e}"))?;
11201        out.push_str("=== Unix Login History (last) ===\n");
11202        out.push_str(&String::from_utf8_lossy(&output.stdout));
11203    }
11204
11205    Ok(out.trim_end().to_string())
11206}
11207
11208fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11209    let mut out = String::from("Host inspection: share_access\n\n");
11210    out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11211
11212    #[cfg(target_os = "windows")]
11213    {
11214        let script = format!(
11215            r#"
11216$p = '{}'
11217$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11218if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11219    $res.Reachable = $true
11220    try {{
11221        $null = Get-ChildItem -Path $p -ErrorAction Stop
11222        $res.Readable = $true
11223    }} catch {{
11224        $res.Error = $_.Exception.Message
11225    }}
11226}} else {{
11227    $res.Error = "Server unreachable (Ping failed)"
11228}}
11229"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11230            path.display()
11231        );
11232
11233        let output = Command::new("powershell")
11234            .args(["-NoProfile", "-Command", &script])
11235            .output()
11236            .map_err(|e| format!("Share test failed: {e}"))?;
11237
11238        let text = String::from_utf8_lossy(&output.stdout);
11239        out.push_str("=== Share Triage Results ===\n");
11240        out.push_str(&text);
11241    }
11242
11243    #[cfg(not(target_os = "windows"))]
11244    {
11245        out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11246    }
11247
11248    Ok(out.trim_end().to_string())
11249}
11250
11251fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11252    let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11253    out.push_str(&format!("Issue: {}\n\n", issue));
11254    out.push_str("Proposed Remediation Steps:\n");
11255    out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11256    out.push_str("   `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11257    out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11258    out.push_str("   `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11259    out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11260    out.push_str("   `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11261    out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11262    out.push_str(
11263        "   `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11264    );
11265
11266    Ok(out)
11267}
11268
11269fn inspect_registry_audit() -> Result<String, String> {
11270    let mut out = String::from("Host inspection: registry_audit\n\n");
11271    out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11272
11273    #[cfg(target_os = "windows")]
11274    {
11275        let script = r#"
11276$findings = @()
11277
11278# 1. Image File Execution Options (Debugger Hijacking)
11279$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11280if (Test-Path $ifeo) {
11281    Get-ChildItem $ifeo | ForEach-Object {
11282        $p = Get-ItemProperty $_.PSPath
11283        if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11284    }
11285}
11286
11287# 2. Winlogon Shell Integrity
11288$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11289$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11290if ($shell -and $shell -ne "explorer.exe") {
11291    $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11292}
11293
11294# 3. Session Manager BootExecute
11295$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11296$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11297if ($boot -and $boot -notcontains "autocheck autochk *") {
11298    $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11299}
11300
11301if ($findings.Count -eq 0) {
11302    "PASS: No common registry hijacking or shell overrides detected."
11303} else {
11304    $findings -join "`n"
11305}
11306"#;
11307        let output = Command::new("powershell")
11308            .args(["-NoProfile", "-Command", &script])
11309            .output()
11310            .map_err(|e| format!("Registry audit failed: {e}"))?;
11311
11312        let text = String::from_utf8_lossy(&output.stdout);
11313        out.push_str("=== Persistence & Integrity Check ===\n");
11314        out.push_str(&text);
11315    }
11316
11317    #[cfg(not(target_os = "windows"))]
11318    {
11319        out.push_str("Registry auditing is specific to Windows environments.\n");
11320    }
11321
11322    Ok(out.trim_end().to_string())
11323}
11324
11325fn inspect_thermal() -> Result<String, String> {
11326    let mut out = String::from("Host inspection: thermal\n\n");
11327    out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11328
11329    #[cfg(target_os = "windows")]
11330    {
11331        let script = r#"
11332$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11333if ($thermal) {
11334    $thermal | ForEach-Object {
11335        $temp = [math]::Round(($_.Temperature - 273.15), 1)
11336        "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11337    }
11338} else {
11339    "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11340    $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11341    "Current CPU Load: $throttling%"
11342}
11343"#;
11344        let output = Command::new("powershell")
11345            .args(["-NoProfile", "-Command", script])
11346            .output()
11347            .map_err(|e| format!("Thermal check failed: {e}"))?;
11348        out.push_str("=== Windows Thermal State ===\n");
11349        out.push_str(&String::from_utf8_lossy(&output.stdout));
11350    }
11351
11352    #[cfg(not(target_os = "windows"))]
11353    {
11354        out.push_str(
11355            "Thermal inspection is currently optimized for Windows performance counters.\n",
11356        );
11357    }
11358
11359    Ok(out.trim_end().to_string())
11360}
11361
11362fn inspect_activation() -> Result<String, String> {
11363    let mut out = String::from("Host inspection: activation\n\n");
11364    out.push_str("Auditing Windows activation and license state...\n\n");
11365
11366    #[cfg(target_os = "windows")]
11367    {
11368        let script = r#"
11369$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11370$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
11371"Status: $($xpr.Trim())"
11372"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
11373"#;
11374        let output = Command::new("powershell")
11375            .args(["-NoProfile", "-Command", script])
11376            .output()
11377            .map_err(|e| format!("Activation check failed: {e}"))?;
11378        out.push_str("=== Windows License Report ===\n");
11379        out.push_str(&String::from_utf8_lossy(&output.stdout));
11380    }
11381
11382    #[cfg(not(target_os = "windows"))]
11383    {
11384        out.push_str("Windows activation check is specific to the Windows platform.\n");
11385    }
11386
11387    Ok(out.trim_end().to_string())
11388}
11389
11390fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
11391    let mut out = String::from("Host inspection: patch_history\n\n");
11392    out.push_str(&format!(
11393        "Listing the last {} installed Windows updates (KBs)...\n\n",
11394        max_entries
11395    ));
11396
11397    #[cfg(target_os = "windows")]
11398    {
11399        let n = max_entries.clamp(1, 50);
11400        let script = format!(
11401            "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
11402            n
11403        );
11404        let output = Command::new("powershell")
11405            .args(["-NoProfile", "-Command", &script])
11406            .output()
11407            .map_err(|e| format!("Patch history query failed: {e}"))?;
11408        out.push_str("=== Recent HotFixes (KBs) ===\n");
11409        out.push_str(&String::from_utf8_lossy(&output.stdout));
11410    }
11411
11412    #[cfg(not(target_os = "windows"))]
11413    {
11414        out.push_str("Patch history is currently focused on Windows HotFixes.\n");
11415    }
11416
11417    Ok(out.trim_end().to_string())
11418}
11419
11420// ── ad_user ──────────────────────────────────────────────────────────────────
11421
11422fn inspect_ad_user(identity: &str) -> Result<String, String> {
11423    let mut out = String::from("Host inspection: ad_user\n\n");
11424    let ident = identity.trim();
11425    if ident.is_empty() {
11426        out.push_str("Status: No identity specified. Performing self-discovery...\n");
11427        #[cfg(target_os = "windows")]
11428        {
11429            let script = r#"
11430$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
11431"USER: " + $u.Name
11432"SID: " + $u.User.Value
11433"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
11434"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
11435"#;
11436            let output = Command::new("powershell")
11437                .args(["-NoProfile", "-Command", script])
11438                .output()
11439                .ok();
11440            if let Some(o) = output {
11441                out.push_str(&String::from_utf8_lossy(&o.stdout));
11442            }
11443        }
11444        return Ok(out);
11445    }
11446
11447    #[cfg(target_os = "windows")]
11448    {
11449        let script = format!(
11450            r#"
11451try {{
11452    $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
11453    "NAME: " + $u.Name
11454    "SID: " + $u.SID
11455    "ENABLED: " + $u.Enabled
11456    "EXPIRED: " + $u.PasswordExpired
11457    "LOGON: " + $u.LastLogonDate
11458    "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
11459}} catch {{
11460    # Fallback to net user if AD module is missing or fails
11461    $net = net user "{ident}" /domain 2>&1
11462    if ($LASTEXITCODE -eq 0) {{
11463        $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
11464    }} else {{
11465        "ERROR: " + $_.Exception.Message
11466    }}
11467}}"#
11468        );
11469
11470        let output = Command::new("powershell")
11471            .args(["-NoProfile", "-Command", &script])
11472            .output()
11473            .ok();
11474
11475        if let Some(o) = output {
11476            let stdout = String::from_utf8_lossy(&o.stdout);
11477            if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
11478                out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
11479            }
11480            out.push_str(&stdout);
11481        }
11482    }
11483
11484    #[cfg(not(target_os = "windows"))]
11485    {
11486        let _ = ident;
11487        out.push_str("(AD User lookup only available on Windows nodes)\n");
11488    }
11489
11490    Ok(out.trim_end().to_string())
11491}
11492
11493// ── dns_lookup ───────────────────────────────────────────────────────────────
11494
11495fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
11496    let mut out = String::from("Host inspection: dns_lookup\n\n");
11497    let target = name.trim();
11498    if target.is_empty() {
11499        return Err("Missing required target name for dns_lookup.".to_string());
11500    }
11501
11502    #[cfg(target_os = "windows")]
11503    {
11504        let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
11505        let output = Command::new("powershell")
11506            .args(["-NoProfile", "-Command", &script])
11507            .output()
11508            .ok();
11509        if let Some(o) = output {
11510            let stdout = String::from_utf8_lossy(&o.stdout);
11511            if stdout.trim().is_empty() {
11512                out.push_str(&format!("No {record_type} records found for {target}.\n"));
11513            } else {
11514                out.push_str(&stdout);
11515            }
11516        }
11517    }
11518
11519    #[cfg(not(target_os = "windows"))]
11520    {
11521        let output = Command::new("dig")
11522            .args([target, record_type, "+short"])
11523            .output()
11524            .ok();
11525        if let Some(o) = output {
11526            out.push_str(&String::from_utf8_lossy(&o.stdout));
11527        }
11528    }
11529
11530    Ok(out.trim_end().to_string())
11531}
11532
11533// ── hyperv ───────────────────────────────────────────────────────────────────
11534
11535#[cfg(target_os = "windows")]
11536fn ps_exec(script: &str) -> String {
11537    Command::new("powershell")
11538        .args(["-NoProfile", "-NonInteractive", "-Command", script])
11539        .output()
11540        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
11541        .unwrap_or_default()
11542}
11543
11544fn inspect_mdm_enrollment() -> Result<String, String> {
11545    #[cfg(target_os = "windows")]
11546    {
11547        let mut out = String::from("Host inspection: mdm_enrollment\n\n");
11548
11549        // ── dsregcmd /status — primary enrollment signal ──────────────────────
11550        out.push_str("=== Device join and MDM state (dsregcmd) ===\n");
11551        let ps_dsreg = r#"
11552$raw = dsregcmd /status 2>$null
11553$fields = @('AzureAdJoined','EnterpriseJoined','DomainJoined','MdmEnrolled',
11554            'WamDefaultSet','AzureAdPrt','TenantName','TenantId','MdmUrl','MdmTouUrl')
11555foreach ($line in $raw) {
11556    $t = $line.Trim()
11557    foreach ($f in $fields) {
11558        if ($t -like "$f :*") {
11559            $val = ($t -split ':',2)[1].Trim()
11560            "$f`: $val"
11561        }
11562    }
11563}
11564"#;
11565        match run_powershell(ps_dsreg) {
11566            Ok(o) if !o.trim().is_empty() => {
11567                for line in o.lines() {
11568                    let l = line.trim();
11569                    if !l.is_empty() {
11570                        out.push_str(&format!("- {l}\n"));
11571                    }
11572                }
11573            }
11574            Ok(_) => out.push_str(
11575                "- dsregcmd returned no enrollment fields (device may not be AAD-joined)\n",
11576            ),
11577            Err(e) => out.push_str(&format!("- dsregcmd error: {e}\n")),
11578        }
11579
11580        // ── Registry enrollment accounts ──────────────────────────────────────
11581        out.push_str("\n=== Enrollment accounts (registry) ===\n");
11582        let ps_enroll = r#"
11583$base = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
11584if (Test-Path $base) {
11585    $accounts = Get-ChildItem $base -ErrorAction SilentlyContinue
11586    if ($accounts) {
11587        foreach ($acct in $accounts) {
11588            $p = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
11589            $upn    = if ($p.UPN)                { $p.UPN }                else { '(none)' }
11590            $server = if ($p.EnrollmentServerUrl){ $p.EnrollmentServerUrl }else { '(none)' }
11591            $type   = switch ($p.EnrollmentType) {
11592                6  { 'MDM' }
11593                13 { 'MAM' }
11594                default { "Type=$($p.EnrollmentType)" }
11595            }
11596            $state  = switch ($p.EnrollmentState) {
11597                1  { 'Enrolled' }
11598                2  { 'InProgress' }
11599                6  { 'Unenrolled' }
11600                default { "State=$($p.EnrollmentState)" }
11601            }
11602            "Account: $upn | $type | $state | $server"
11603        }
11604    } else { "No enrollment accounts found under $base" }
11605} else { "Enrollment registry key not found — device is not MDM-enrolled" }
11606"#;
11607        match run_powershell(ps_enroll) {
11608            Ok(o) => {
11609                for line in o.lines() {
11610                    let l = line.trim();
11611                    if !l.is_empty() {
11612                        out.push_str(&format!("- {l}\n"));
11613                    }
11614                }
11615            }
11616            Err(e) => out.push_str(&format!("- Registry read error: {e}\n")),
11617        }
11618
11619        // ── MDM service health ────────────────────────────────────────────────
11620        out.push_str("\n=== MDM services ===\n");
11621        let ps_svc = r#"
11622$names = @('IntuneManagementExtension','dmwappushservice','Microsoft.Management.Services.IntuneWindowsAgent')
11623foreach ($n in $names) {
11624    $s = Get-Service -Name $n -ErrorAction SilentlyContinue
11625    if ($s) { "$($s.Name): $($s.Status) (StartType: $($s.StartType))" }
11626}
11627"#;
11628        match run_powershell(ps_svc) {
11629            Ok(o) if !o.trim().is_empty() => {
11630                for line in o.lines() {
11631                    let l = line.trim();
11632                    if !l.is_empty() {
11633                        out.push_str(&format!("- {l}\n"));
11634                    }
11635                }
11636            }
11637            Ok(_) => out.push_str("- No Intune management services found (unmanaged device or extension not installed)\n"),
11638            Err(e) => out.push_str(&format!("- Service query error: {e}\n")),
11639        }
11640
11641        // ── Recent MDM / Intune events ────────────────────────────────────────
11642        out.push_str("\n=== Recent MDM events (last 24h) ===\n");
11643        let ps_evt = r#"
11644$logs = @('Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin',
11645          'Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot')
11646$cutoff = (Get-Date).AddHours(-24)
11647$found = $false
11648foreach ($log in $logs) {
11649    $evts = Get-WinEvent -LogName $log -MaxEvents 20 -ErrorAction SilentlyContinue |
11650            Where-Object { $_.TimeCreated -gt $cutoff -and $_.Level -le 3 }
11651    foreach ($e in $evts) {
11652        $found = $true
11653        $ts = $e.TimeCreated.ToString('HH:mm')
11654        $lvl = if ($e.Level -eq 2) { 'ERR' } else { 'WARN' }
11655        "[$lvl $ts] ID=$($e.Id) — $($e.Message.Split("`n")[0].Trim())"
11656    }
11657}
11658if (-not $found) { "No MDM warning/error events in the last 24 hours" }
11659"#;
11660        match run_powershell(ps_evt) {
11661            Ok(o) => {
11662                for line in o.lines() {
11663                    let l = line.trim();
11664                    if !l.is_empty() {
11665                        out.push_str(&format!("- {l}\n"));
11666                    }
11667                }
11668            }
11669            Err(e) => out.push_str(&format!("- Event log read error: {e}\n")),
11670        }
11671
11672        // ── Findings ──────────────────────────────────────────────────────────
11673        out.push_str("\n=== Findings ===\n");
11674        let body = out.clone();
11675        let enrolled = body.contains("MdmEnrolled: YES") || body.contains("| Enrolled |");
11676        let intune_running = body.contains("IntuneManagementExtension: Running");
11677        let has_errors = body.contains("[ERR ") || body.contains("[WARN ");
11678
11679        if !enrolled {
11680            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");
11681        } else {
11682            out.push_str("- ENROLLED: Device has an active MDM enrollment.\n");
11683            if !intune_running {
11684                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");
11685            }
11686        }
11687        if has_errors {
11688            out.push_str("- MDM error/warning events detected — review the events section above for blockers.\n");
11689        }
11690        if !enrolled && !has_errors {
11691            out.push_str("- No MDM error events detected. If enrollment is required, initiate from Settings > Accounts > Access work or school > Connect.\n");
11692        }
11693
11694        Ok(out)
11695    }
11696
11697    #[cfg(not(target_os = "windows"))]
11698    {
11699        Ok("Host inspection: mdm_enrollment\n\n=== Findings ===\n- MDM/Intune enrollment inspection is Windows-only.\n".into())
11700    }
11701}
11702
11703fn inspect_hyperv() -> Result<String, String> {
11704    #[cfg(target_os = "windows")]
11705    {
11706        let mut findings: Vec<String> = Vec::new();
11707        let mut out = String::new();
11708
11709        // --- Hyper-V role / VMMS service state ---
11710        let ps_role = r#"
11711$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
11712$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
11713$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
11714$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
11715"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
11716    $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
11717    $(if ($feature) { $feature.State } else { "Unknown" }),
11718    $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
11719    $(if ($ram) { $ram } else { "0" })
11720"#;
11721        let role_out = ps_exec(ps_role);
11722        out.push_str("=== Hyper-V role state ===\n");
11723
11724        let mut vmms_running = false;
11725        let mut host_ram_bytes: u64 = 0;
11726
11727        if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
11728            let kv: std::collections::HashMap<&str, &str> = line
11729                .split('|')
11730                .filter_map(|p| {
11731                    let mut it = p.splitn(2, ':');
11732                    Some((it.next()?, it.next()?))
11733                })
11734                .collect();
11735            let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
11736            let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
11737            let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
11738            host_ram_bytes = kv
11739                .get("HostRAMBytes")
11740                .and_then(|v| v.parse().ok())
11741                .unwrap_or(0);
11742
11743            let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
11744            vmms_running = vmms_status.starts_with("Running");
11745
11746            out.push_str(&format!("- Host: {host_name}\n"));
11747            out.push_str(&format!(
11748                "- Hyper-V feature: {}\n",
11749                if hyperv_installed {
11750                    "Enabled"
11751                } else {
11752                    "Not installed"
11753                }
11754            ));
11755            out.push_str(&format!("- VMMS service: {vmms_status}\n"));
11756            if host_ram_bytes > 0 {
11757                out.push_str(&format!(
11758                    "- Host physical RAM: {} GB\n",
11759                    host_ram_bytes / 1_073_741_824
11760                ));
11761            }
11762
11763            if !hyperv_installed {
11764                findings.push(
11765                    "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
11766                );
11767            } else if !vmms_running {
11768                findings.push(
11769                    "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
11770                );
11771            }
11772        } else {
11773            out.push_str("- Could not determine Hyper-V role state\n");
11774            findings.push("Hyper-V does not appear to be installed on this machine.".into());
11775        }
11776
11777        // --- Virtual machines ---
11778        out.push_str("\n=== Virtual machines ===\n");
11779        if vmms_running {
11780            let ps_vms = r#"
11781Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
11782    $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
11783    "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
11784        $_.Name, $_.State, $_.CPUUsage, $ram_gb,
11785        $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
11786        $_.Status, $_.Generation
11787}
11788"#;
11789            let vms_out = ps_exec(ps_vms);
11790            let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
11791
11792            if vm_lines.is_empty() {
11793                out.push_str("- No virtual machines found on this host\n");
11794            } else {
11795                let mut total_ram_bytes: u64 = 0;
11796                let mut saved_vms: Vec<String> = Vec::new();
11797                for line in &vm_lines {
11798                    let kv: std::collections::HashMap<&str, &str> = line
11799                        .split('|')
11800                        .filter_map(|p| {
11801                            let mut it = p.splitn(2, ':');
11802                            Some((it.next()?, it.next()?))
11803                        })
11804                        .collect();
11805                    let name = kv.get("VM").copied().unwrap_or("Unknown");
11806                    let state = kv.get("State").copied().unwrap_or("Unknown");
11807                    let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
11808                    let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
11809                    let uptime = kv.get("Uptime").copied().unwrap_or("Off");
11810                    let status = kv.get("Status").copied().unwrap_or("");
11811                    let gen = kv.get("Generation").copied().unwrap_or("?");
11812
11813                    if let Ok(r) = ram.parse::<f64>() {
11814                        total_ram_bytes += (r * 1_073_741_824.0) as u64;
11815                    }
11816                    if state.eq_ignore_ascii_case("Saved") {
11817                        saved_vms.push(name.to_string());
11818                    }
11819
11820                    out.push_str(&format!(
11821                        "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}\n"
11822                    ));
11823                    if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
11824                        out.push_str(&format!("  Status: {status}\n"));
11825                    }
11826                }
11827
11828                out.push_str(&format!("\n- Total VMs: {}\n", vm_lines.len()));
11829                if total_ram_bytes > 0 && host_ram_bytes > 0 {
11830                    let pct = (total_ram_bytes * 100) / host_ram_bytes;
11831                    out.push_str(&format!(
11832                        "- Total VM RAM assigned: {} GB ({pct}% of host RAM)\n",
11833                        total_ram_bytes / 1_073_741_824
11834                    ));
11835                    if pct > 90 {
11836                        findings.push(format!(
11837                            "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
11838                        ));
11839                    }
11840                }
11841                if !saved_vms.is_empty() {
11842                    findings.push(format!(
11843                        "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
11844                        saved_vms.join(", ")
11845                    ));
11846                }
11847            }
11848        } else {
11849            out.push_str("- VMMS not running — cannot enumerate VMs\n");
11850        }
11851
11852        // --- VM network switches ---
11853        out.push_str("\n=== VM network switches ===\n");
11854        if vmms_running {
11855            let ps_switches = r#"
11856Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
11857    "Switch:{0}|Type:{1}|Adapter:{2}" -f `
11858        $_.Name, $_.SwitchType,
11859        $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
11860}
11861"#;
11862            let sw_out = ps_exec(ps_switches);
11863            let switch_lines: Vec<&str> = sw_out
11864                .lines()
11865                .filter(|l| l.starts_with("Switch:"))
11866                .collect();
11867
11868            if switch_lines.is_empty() {
11869                out.push_str("- No VM switches configured\n");
11870            } else {
11871                for line in &switch_lines {
11872                    let kv: std::collections::HashMap<&str, &str> = line
11873                        .split('|')
11874                        .filter_map(|p| {
11875                            let mut it = p.splitn(2, ':');
11876                            Some((it.next()?, it.next()?))
11877                        })
11878                        .collect();
11879                    let name = kv.get("Switch").copied().unwrap_or("Unknown");
11880                    let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
11881                    let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
11882                    out.push_str(&format!("- {name} | Type: {sw_type} | NIC: {adapter}\n"));
11883                }
11884            }
11885        } else {
11886            out.push_str("- VMMS not running — cannot enumerate switches\n");
11887        }
11888
11889        // --- VM checkpoints ---
11890        out.push_str("\n=== VM checkpoints ===\n");
11891        if vmms_running {
11892            let ps_checkpoints = r#"
11893$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
11894if ($all) {
11895    $all | ForEach-Object {
11896        "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
11897            $_.Name, $_.VMName,
11898            $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
11899            $_.SnapshotType
11900    }
11901} else {
11902    "NONE"
11903}
11904"#;
11905            let cp_out = ps_exec(ps_checkpoints);
11906            if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
11907                out.push_str("- No checkpoints found\n");
11908            } else {
11909                let cp_lines: Vec<&str> = cp_out
11910                    .lines()
11911                    .filter(|l| l.starts_with("Checkpoint:"))
11912                    .collect();
11913                let mut per_vm: std::collections::HashMap<&str, usize> =
11914                    std::collections::HashMap::new();
11915                for line in &cp_lines {
11916                    let kv: std::collections::HashMap<&str, &str> = line
11917                        .split('|')
11918                        .filter_map(|p| {
11919                            let mut it = p.splitn(2, ':');
11920                            Some((it.next()?, it.next()?))
11921                        })
11922                        .collect();
11923                    let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
11924                    let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
11925                    let created = kv.get("Created").copied().unwrap_or("");
11926                    let cp_type = kv.get("Type").copied().unwrap_or("");
11927                    out.push_str(&format!(
11928                        "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}\n"
11929                    ));
11930                    *per_vm.entry(vm_name).or_insert(0) += 1;
11931                }
11932                for (vm, count) in &per_vm {
11933                    if *count >= 3 {
11934                        findings.push(format!(
11935                            "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
11936                        ));
11937                    }
11938                }
11939            }
11940        } else {
11941            out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
11942        }
11943
11944        let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
11945        if findings.is_empty() {
11946            result.push_str("- No Hyper-V health issues detected.\n");
11947        } else {
11948            for f in &findings {
11949                result.push_str(&format!("- Finding: {f}\n"));
11950            }
11951        }
11952        result.push('\n');
11953        result.push_str(&out);
11954        return Ok(result.trim_end().to_string());
11955    }
11956
11957    #[cfg(not(target_os = "windows"))]
11958    Ok(
11959        "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
11960            .into(),
11961    )
11962}
11963
11964// ── ip_config ────────────────────────────────────────────────────────────────
11965
11966fn inspect_ip_config() -> Result<String, String> {
11967    let mut out = String::from("Host inspection: ip_config\n\n");
11968
11969    #[cfg(target_os = "windows")]
11970    {
11971        let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
11972            $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
11973            '\\n  Status: ' + $_.NetAdapter.Status + \
11974            '\\n  Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
11975            '\\n  DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
11976            '\\n  DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11977            '\\n  IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11978            '\\n  DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
11979        }";
11980        let output = Command::new("powershell")
11981            .args(["-NoProfile", "-Command", script])
11982            .output()
11983            .ok();
11984        if let Some(o) = output {
11985            out.push_str(&String::from_utf8_lossy(&o.stdout));
11986        }
11987    }
11988
11989    #[cfg(not(target_os = "windows"))]
11990    {
11991        let output = Command::new("ip").args(["addr", "show"]).output().ok();
11992        if let Some(o) = output {
11993            out.push_str(&String::from_utf8_lossy(&o.stdout));
11994        }
11995    }
11996
11997    Ok(out.trim_end().to_string())
11998}
11999
12000// ── event_query ──────────────────────────────────────────────────────────────
12001
12002fn inspect_event_query(
12003    event_id: Option<u32>,
12004    log_name: Option<&str>,
12005    source: Option<&str>,
12006    hours: u32,
12007    level: Option<&str>,
12008    max_entries: usize,
12009) -> Result<String, String> {
12010    #[cfg(target_os = "windows")]
12011    {
12012        let mut findings: Vec<String> = Vec::new();
12013
12014        // Build the PowerShell filter hash
12015        let log = log_name.unwrap_or("*");
12016        let cap = max_entries.min(50);
12017
12018        // Level mapping: Error=2, Warning=3, Information=4
12019        let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
12020            Some("error") | Some("errors") => Some(2u8),
12021            Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
12022            Some("information") | Some("info") => Some(4u8),
12023            _ => None,
12024        };
12025
12026        // Build filter hashtable entries
12027        let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
12028        if log != "*" {
12029            filter_parts.push(format!("LogName = '{log}'"));
12030        }
12031        if let Some(id) = event_id {
12032            filter_parts.push(format!("Id = {id}"));
12033        }
12034        if let Some(src) = source {
12035            filter_parts.push(format!("ProviderName = '{src}'"));
12036        }
12037        if let Some(lvl) = level_filter {
12038            filter_parts.push(format!("Level = {lvl}"));
12039        }
12040
12041        let filter_ht = filter_parts.join("; ");
12042
12043        let ps = format!(
12044            r#"
12045$filter = @{{ {filter_ht} }}
12046try {{
12047    $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
12048        Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
12049            @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
12050    if ($events) {{
12051        $events | ForEach-Object {{
12052            "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
12053                $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
12054                $_.Id, $_.LevelDisplayName, $_.ProviderName,
12055                ($_.Msg -replace '\|','/')
12056        }}
12057    }} else {{
12058        "NONE"
12059    }}
12060}} catch {{
12061    "ERROR:$($_.Exception.Message)"
12062}}
12063"#
12064        );
12065
12066        let raw = ps_exec(&ps);
12067        let lines: Vec<&str> = raw.lines().collect();
12068
12069        // Build query description for header
12070        let mut query_desc = format!("last {hours}h");
12071        if let Some(id) = event_id {
12072            query_desc.push_str(&format!(", Event ID {id}"));
12073        }
12074        if let Some(src) = source {
12075            query_desc.push_str(&format!(", source '{src}'"));
12076        }
12077        if log != "*" {
12078            query_desc.push_str(&format!(", log '{log}'"));
12079        }
12080        if let Some(l) = level {
12081            query_desc.push_str(&format!(", level '{l}'"));
12082        }
12083
12084        let mut out = format!("=== Event query: {query_desc} ===\n");
12085
12086        if lines
12087            .iter()
12088            .any(|l| l.trim() == "NONE" || l.trim().is_empty())
12089        {
12090            out.push_str("- No matching events found.\n");
12091        } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
12092            let msg = err_line.trim_start_matches("ERROR:").trim();
12093            if is_event_query_no_results_message(msg) {
12094                out.push_str("- No matching events found.\n");
12095            } else {
12096                out.push_str(&format!("- Query error: {msg}\n"));
12097                findings.push(format!("Event query failed: {msg}"));
12098            }
12099        } else {
12100            let event_lines: Vec<&str> = lines
12101                .iter()
12102                .filter(|l| l.starts_with("TIME:"))
12103                .copied()
12104                .collect();
12105            if event_lines.is_empty() {
12106                out.push_str("- No matching events found.\n");
12107            } else {
12108                // Tally by level for findings
12109                let mut error_count = 0usize;
12110                let mut warning_count = 0usize;
12111
12112                for line in &event_lines {
12113                    let kv: std::collections::HashMap<&str, &str> = line
12114                        .split('|')
12115                        .filter_map(|p| {
12116                            let mut it = p.splitn(2, ':');
12117                            Some((it.next()?, it.next()?))
12118                        })
12119                        .collect();
12120                    let time = kv.get("TIME").copied().unwrap_or("?");
12121                    let id = kv.get("ID").copied().unwrap_or("?");
12122                    let lvl = kv.get("LEVEL").copied().unwrap_or("?");
12123                    let src = kv.get("SOURCE").copied().unwrap_or("?");
12124                    let msg = kv.get("MSG").copied().unwrap_or("").trim();
12125
12126                    // Truncate long messages
12127                    let msg_display = if msg.len() > 120 {
12128                        format!("{}…", &msg[..120])
12129                    } else {
12130                        msg.to_string()
12131                    };
12132
12133                    out.push_str(&format!(
12134                        "- [{time}] ID {id} | {lvl} | {src}\n  {msg_display}\n"
12135                    ));
12136
12137                    if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
12138                        error_count += 1;
12139                    } else if lvl.eq_ignore_ascii_case("warning") {
12140                        warning_count += 1;
12141                    }
12142                }
12143
12144                out.push_str(&format!(
12145                    "\n- Total shown: {} event(s)\n",
12146                    event_lines.len()
12147                ));
12148
12149                if error_count > 0 {
12150                    findings.push(format!(
12151                        "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
12152                    ));
12153                }
12154                if warning_count > 5 {
12155                    findings.push(format!(
12156                        "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
12157                    ));
12158                }
12159            }
12160        }
12161
12162        let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
12163        if findings.is_empty() {
12164            result.push_str("- No actionable findings from this event query.\n");
12165        } else {
12166            for f in &findings {
12167                result.push_str(&format!("- Finding: {f}\n"));
12168            }
12169        }
12170        result.push('\n');
12171        result.push_str(&out);
12172        return Ok(result.trim_end().to_string());
12173    }
12174
12175    #[cfg(not(target_os = "windows"))]
12176    {
12177        let _ = (event_id, log_name, source, hours, level, max_entries);
12178        Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
12179    }
12180}
12181
12182// ── app_crashes ───────────────────────────────────────────────────────────────
12183
12184fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
12185    let n = max_entries.clamp(5, 50);
12186    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12187    let mut findings: Vec<String> = Vec::new();
12188    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
12189    let mut sections = String::new();
12190
12191    #[cfg(target_os = "windows")]
12192    {
12193        let proc_filter_ps = match process_filter {
12194            Some(proc) => format!(
12195                "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12196                proc.replace('\'', "''")
12197            ),
12198            None => String::new(),
12199        };
12200
12201        let ps = format!(
12202            r#"
12203$results = @()
12204try {{
12205    $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12206    if ($events) {{
12207        foreach ($e in $events) {{
12208            $msg  = $e.Message
12209            $app  = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12210            $ver  = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12211            $mod  = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12212            $exc  = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12213            $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12214            $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12215        }}
12216        $results
12217    }} else {{ 'NONE' }}
12218}} catch {{ 'ERROR:' + $_.Exception.Message }}
12219"#
12220        );
12221
12222        let raw = ps_exec(&ps);
12223        let text = raw.trim();
12224
12225        // WER archive count (non-blocking best-effort)
12226        let wer_ps = r#"
12227$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12228$count = 0
12229if (Test-Path $wer) {
12230    $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12231}
12232$count
12233"#;
12234        let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12235
12236        if text == "NONE" {
12237            sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12238        } else if text.starts_with("ERROR:") {
12239            let msg = text.trim_start_matches("ERROR:").trim();
12240            sections.push_str(&format!(
12241                "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12242            ));
12243        } else {
12244            let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12245            let crash_count = events
12246                .iter()
12247                .filter(|l| l.splitn(3, '|').nth(1) == Some("CRASH"))
12248                .count();
12249            let hang_count = events
12250                .iter()
12251                .filter(|l| l.splitn(3, '|').nth(1) == Some("HANG"))
12252                .count();
12253
12254            // Tally crashes per app
12255            let mut app_counts: std::collections::HashMap<String, usize> =
12256                std::collections::HashMap::new();
12257            for line in &events {
12258                let parts: Vec<&str> = line.splitn(6, '|').collect();
12259                if parts.len() >= 3 {
12260                    *app_counts.entry(parts[2].to_string()).or_insert(0) += 1;
12261                }
12262            }
12263
12264            if crash_count > 0 {
12265                findings.push(format!(
12266                    "{crash_count} application crash event(s) — review below for faulting app and exception code."
12267                ));
12268            }
12269            if hang_count > 0 {
12270                findings.push(format!(
12271                    "{hang_count} application hang event(s) — process stopped responding."
12272                ));
12273            }
12274            if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12275                if count > 1 {
12276                    findings.push(format!(
12277                        "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
12278                    ));
12279                }
12280            }
12281            if wer_count > 10 {
12282                findings.push(format!(
12283                    "{wer_count} WER reports archived — elevated crash history on this machine."
12284                ));
12285            }
12286
12287            let filter_note = match process_filter {
12288                Some(p) => format!(" (filtered: {p})"),
12289                None => String::new(),
12290            };
12291            sections.push_str(&format!(
12292                "=== Application crashes and hangs{filter_note} ===\n"
12293            ));
12294
12295            for line in &events {
12296                let parts: Vec<&str> = line.splitn(6, '|').collect();
12297                if parts.len() >= 6 {
12298                    let time = parts[0];
12299                    let kind = parts[1];
12300                    let app = parts[2];
12301                    let ver = parts[3];
12302                    let module = parts[4];
12303                    let exc = parts[5];
12304                    let ver_note = if !ver.is_empty() {
12305                        format!(" v{ver}")
12306                    } else {
12307                        String::new()
12308                    };
12309                    sections.push_str(&format!("  [{time}] {kind}: {app}{ver_note}\n"));
12310                    if !module.is_empty() && module != "?" {
12311                        let exc_note = if !exc.is_empty() {
12312                            format!(" (exc {exc})")
12313                        } else {
12314                            String::new()
12315                        };
12316                        sections.push_str(&format!("    faulting module: {module}{exc_note}\n"));
12317                    } else if !exc.is_empty() {
12318                        sections.push_str(&format!("    exception: {exc}\n"));
12319                    }
12320                }
12321            }
12322            sections.push_str(&format!(
12323                "\n  Total: {crash_count} crash(es), {hang_count} hang(s)\n"
12324            ));
12325
12326            if wer_count > 0 {
12327                sections.push_str(&format!(
12328                    "\n=== Windows Error Reporting ===\n  WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
12329                ));
12330            }
12331        }
12332    }
12333
12334    #[cfg(not(target_os = "windows"))]
12335    {
12336        let _ = (process_filter, n);
12337        sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
12338    }
12339
12340    let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
12341    if findings.is_empty() {
12342        result.push_str("- No actionable findings.\n");
12343    } else {
12344        for f in &findings {
12345            result.push_str(&format!("- Finding: {f}\n"));
12346        }
12347    }
12348    result.push('\n');
12349    result.push_str(&sections);
12350    Ok(result.trim_end().to_string())
12351}
12352
12353#[cfg(target_os = "windows")]
12354fn gpu_voltage_telemetry_note() -> String {
12355    let output = Command::new("nvidia-smi")
12356        .args(["--help-query-gpu"])
12357        .output();
12358
12359    match output {
12360        Ok(o) => {
12361            let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
12362            if text.contains("\"voltage\"") || text.contains("voltage.") {
12363                "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
12364            } else {
12365                "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()
12366            }
12367        }
12368        Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
12369    }
12370}
12371
12372#[cfg(target_os = "windows")]
12373fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
12374    if raw == 0 {
12375        return None;
12376    }
12377    if raw & 0x80 != 0 {
12378        let tenths = raw & 0x7f;
12379        return Some(format!(
12380            "{:.1} V (firmware-reported WMI current voltage)",
12381            tenths as f64 / 10.0
12382        ));
12383    }
12384
12385    let legacy = match raw {
12386        1 => Some("5.0 V"),
12387        2 => Some("3.3 V"),
12388        4 => Some("2.9 V"),
12389        _ => None,
12390    }?;
12391    Some(format!(
12392        "{} (legacy WMI voltage capability flag, not live telemetry)",
12393        legacy
12394    ))
12395}
12396
12397async fn inspect_overclocker() -> Result<String, String> {
12398    let mut out = String::from("Host inspection: overclocker\n\n");
12399
12400    #[cfg(target_os = "windows")]
12401    {
12402        out.push_str(
12403            "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
12404        );
12405
12406        // 1. NVIDIA Census
12407        let nvidia = Command::new("nvidia-smi")
12408            .args([
12409                "--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",
12410                "--format=csv,noheader,nounits",
12411            ])
12412            .output();
12413
12414        if let Ok(o) = nvidia {
12415            let stdout = String::from_utf8_lossy(&o.stdout);
12416            if !stdout.trim().is_empty() {
12417                out.push_str("=== GPU SENSE (NVIDIA) ===\n");
12418                let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
12419                if parts.len() >= 10 {
12420                    out.push_str(&format!("- Model:      {}\n", parts[0]));
12421                    out.push_str(&format!("- Graphics:   {} MHz\n", parts[1]));
12422                    out.push_str(&format!("- Memory:     {} MHz\n", parts[2]));
12423                    out.push_str(&format!("- Fan Speed:  {}%\n", parts[3]));
12424                    out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
12425                    if !parts[6].eq_ignore_ascii_case("[N/A]") {
12426                        out.push_str(&format!("- Power Avg:  {} W\n", parts[6]));
12427                    }
12428                    if !parts[7].eq_ignore_ascii_case("[N/A]") {
12429                        out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
12430                    }
12431                    if !parts[8].eq_ignore_ascii_case("[N/A]") {
12432                        out.push_str(&format!("- Power Cap:  {} W requested\n", parts[8]));
12433                    }
12434                    if !parts[9].eq_ignore_ascii_case("[N/A]") {
12435                        out.push_str(&format!("- Power Enf:  {} W enforced\n", parts[9]));
12436                    }
12437                    out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
12438
12439                    if parts.len() > 10 {
12440                        let throttle_hex = parts[10];
12441                        let reasons = decode_nvidia_throttle_reasons(throttle_hex);
12442                        if !reasons.is_empty() {
12443                            out.push_str(&format!("- Throttling:  YES [Reason: {}]\n", reasons));
12444                        } else {
12445                            out.push_str("- Throttling:  None (Performance State: Max)\n");
12446                        }
12447                    }
12448                }
12449                out.push_str("\n");
12450            }
12451        }
12452
12453        out.push_str("=== VOLTAGE TELEMETRY ===\n");
12454        out.push_str(&format!(
12455            "- GPU Voltage:  {}\n\n",
12456            gpu_voltage_telemetry_note()
12457        ));
12458
12459        // 1b. Session Trends (RAM-only historians)
12460        let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
12461        let history = gpu_state.history.lock().unwrap();
12462        if history.len() >= 2 {
12463            out.push_str("=== SILICON TRENDS (Session) ===\n");
12464            let first = history.front().unwrap();
12465            let last = history.back().unwrap();
12466
12467            let temp_diff = last.temperature as i32 - first.temperature as i32;
12468            let clock_diff = last.core_clock as i32 - first.core_clock as i32;
12469
12470            let temp_trend = if temp_diff > 1 {
12471                "Rising"
12472            } else if temp_diff < -1 {
12473                "Falling"
12474            } else {
12475                "Stable"
12476            };
12477            let clock_trend = if clock_diff > 10 {
12478                "Increasing"
12479            } else if clock_diff < -10 {
12480                "Decreasing"
12481            } else {
12482                "Stable"
12483            };
12484
12485            out.push_str(&format!(
12486                "- Temperature: {} ({}°C anomaly)\n",
12487                temp_trend, temp_diff
12488            ));
12489            out.push_str(&format!(
12490                "- Core Clock:  {} ({} MHz delta)\n",
12491                clock_trend, clock_diff
12492            ));
12493            out.push_str("\n");
12494        }
12495
12496        // 2. CPU Time-Series (2 samples)
12497        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))\" }";
12498        let cpu_stats = Command::new("powershell")
12499            .args(["-NoProfile", "-Command", ps_cmd])
12500            .output();
12501
12502        if let Ok(o) = cpu_stats {
12503            let stdout = String::from_utf8_lossy(&o.stdout);
12504            if !stdout.trim().is_empty() {
12505                out.push_str("=== SILICON CORE (CPU) ===\n");
12506                for line in stdout.lines() {
12507                    if let Some((path, val)) = line.split_once(':') {
12508                        if path.to_lowercase().contains("processor frequency") {
12509                            out.push_str(&format!("- Current Freq:  {} MHz (2s Avg)\n", val));
12510                        } else if path.to_lowercase().contains("% of maximum frequency") {
12511                            out.push_str(&format!("- Throttling:     {}% of Max Capacity\n", val));
12512                            let throttle_num = val.parse::<f64>().unwrap_or(100.0);
12513                            if throttle_num < 95.0 {
12514                                out.push_str(
12515                                    "  [WARNING] Active downclocking or power-saving detected.\n",
12516                                );
12517                            }
12518                        }
12519                    }
12520                }
12521            }
12522        }
12523
12524        // 2b. CPU Thermal Fallback
12525        let thermal = Command::new("powershell")
12526            .args([
12527                "-NoProfile",
12528                "-Command",
12529                "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
12530            ])
12531            .output();
12532        if let Ok(o) = thermal {
12533            let stdout = String::from_utf8_lossy(&o.stdout);
12534            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12535                let temp = if v.is_array() {
12536                    v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12537                } else {
12538                    v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12539                };
12540                if temp > 1.0 {
12541                    out.push_str(&format!("- CPU Package:   {}°C (ACPI Zone)\n", temp));
12542                }
12543            }
12544        }
12545
12546        // 3. WMI Static Fallback/Context
12547        let wmi = Command::new("powershell")
12548            .args([
12549                "-NoProfile",
12550                "-Command",
12551                "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
12552            ])
12553            .output();
12554
12555        if let Ok(o) = wmi {
12556            let stdout = String::from_utf8_lossy(&o.stdout);
12557            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12558                out.push_str("\n=== HARDWARE DNA ===\n");
12559                out.push_str(&format!(
12560                    "- Rated Max:     {} MHz\n",
12561                    v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
12562                ));
12563                match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
12564                    Some(raw) => {
12565                        if let Some(decoded) = decode_wmi_processor_voltage(raw) {
12566                            out.push_str(&format!("- CPU Voltage:   {}\n", decoded));
12567                        } else {
12568                            out.push_str(
12569                                "- CPU Voltage:   Unavailable or non-telemetry WMI value on this firmware path\n",
12570                            );
12571                        }
12572                    }
12573                    None => out.push_str("- CPU Voltage:   Unavailable on this WMI path\n"),
12574                }
12575            }
12576        }
12577    }
12578
12579    #[cfg(not(target_os = "windows"))]
12580    {
12581        out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
12582    }
12583
12584    Ok(out.trim_end().to_string())
12585}
12586
12587/// Decodes the NVIDIA Clocks Throttle Reasons HEX bitmask.
12588#[cfg(target_os = "windows")]
12589fn decode_nvidia_throttle_reasons(hex: &str) -> String {
12590    let hex = hex.trim().trim_start_matches("0x");
12591    let val = match u64::from_str_radix(hex, 16) {
12592        Ok(v) => v,
12593        Err(_) => return String::new(),
12594    };
12595
12596    if val == 0 {
12597        return String::new();
12598    }
12599
12600    let mut reasons = Vec::new();
12601    if val & 0x01 != 0 {
12602        reasons.push("GPU Idle");
12603    }
12604    if val & 0x02 != 0 {
12605        reasons.push("Applications Clocks Setting");
12606    }
12607    if val & 0x04 != 0 {
12608        reasons.push("SW Power Cap (PL1/PL2)");
12609    }
12610    if val & 0x08 != 0 {
12611        reasons.push("HW Slowdown (Thermal/Power)");
12612    }
12613    if val & 0x10 != 0 {
12614        reasons.push("Sync Boost");
12615    }
12616    if val & 0x20 != 0 {
12617        reasons.push("SW Thermal Slowdown");
12618    }
12619    if val & 0x40 != 0 {
12620        reasons.push("HW Thermal Slowdown");
12621    }
12622    if val & 0x80 != 0 {
12623        reasons.push("HW Power Brake Slowdown");
12624    }
12625    if val & 0x100 != 0 {
12626        reasons.push("Display Clock Setting");
12627    }
12628
12629    reasons.join(", ")
12630}
12631
12632// ── PowerShell helper (used by camera / sign_in / search_index) ───────────────
12633
12634#[cfg(windows)]
12635fn run_powershell(script: &str) -> Result<String, String> {
12636    use std::process::Command;
12637    let out = Command::new("powershell")
12638        .args(["-NoProfile", "-NonInteractive", "-Command", script])
12639        .output()
12640        .map_err(|e| format!("powershell launch failed: {e}"))?;
12641    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
12642}
12643
12644// ── inspect_camera ────────────────────────────────────────────────────────────
12645
12646#[cfg(windows)]
12647fn inspect_camera(max_entries: usize) -> Result<String, String> {
12648    let mut out = String::from("=== Camera devices ===\n");
12649
12650    // PnP camera devices
12651    let ps_devices = r#"
12652Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
12653ForEach-Object {
12654    $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
12655    "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
12656}
12657"#;
12658    match run_powershell(ps_devices) {
12659        Ok(o) if !o.trim().is_empty() => {
12660            for line in o.lines().take(max_entries) {
12661                let l = line.trim();
12662                if !l.is_empty() {
12663                    out.push_str(&format!("- {l}\n"));
12664                }
12665            }
12666        }
12667        _ => out.push_str("- No camera devices found via PnP\n"),
12668    }
12669
12670    // Windows privacy / capability gate
12671    out.push_str("\n=== Windows camera privacy ===\n");
12672    let ps_privacy = r#"
12673$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
12674$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
12675"Global: $global"
12676$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
12677    Where-Object { $_.PSChildName -ne 'NonPackaged' } |
12678    ForEach-Object {
12679        $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
12680        if ($v) { "  $($_.PSChildName): $v" }
12681    }
12682$apps
12683"#;
12684    match run_powershell(ps_privacy) {
12685        Ok(o) if !o.trim().is_empty() => {
12686            for line in o.lines().take(max_entries) {
12687                let l = line.trim_end();
12688                if !l.is_empty() {
12689                    out.push_str(&format!("{l}\n"));
12690                }
12691            }
12692        }
12693        _ => out.push_str("- Could not read camera privacy registry\n"),
12694    }
12695
12696    // Windows Hello camera (IR / face auth)
12697    out.push_str("\n=== Biometric / Hello camera ===\n");
12698    let ps_bio = r#"
12699Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
12700ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
12701"#;
12702    match run_powershell(ps_bio) {
12703        Ok(o) if !o.trim().is_empty() => {
12704            for line in o.lines().take(max_entries) {
12705                let l = line.trim();
12706                if !l.is_empty() {
12707                    out.push_str(&format!("- {l}\n"));
12708                }
12709            }
12710        }
12711        _ => out.push_str("- No biometric devices found\n"),
12712    }
12713
12714    // Findings
12715    let mut findings: Vec<String> = Vec::new();
12716    if out.contains("Status: Error") || out.contains("Status: Unknown") {
12717        findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
12718    }
12719    if out.contains("Global: Deny") {
12720        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());
12721    }
12722
12723    let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
12724    if findings.is_empty() {
12725        result.push_str("- No obvious camera or privacy gate issue detected.\n");
12726        result.push_str("  If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
12727    } else {
12728        for f in &findings {
12729            result.push_str(&format!("- Finding: {f}\n"));
12730        }
12731    }
12732    result.push('\n');
12733    result.push_str(&out);
12734    Ok(result)
12735}
12736
12737#[cfg(not(windows))]
12738fn inspect_camera(_max_entries: usize) -> Result<String, String> {
12739    Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
12740}
12741
12742// ── inspect_sign_in ───────────────────────────────────────────────────────────
12743
12744#[cfg(windows)]
12745fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
12746    let mut out = String::from("=== Windows Hello and sign-in state ===\n");
12747
12748    // Windows Hello PIN and face/fingerprint readiness
12749    let ps_hello = r#"
12750$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
12751$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
12752$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
12753"PIN-style logon path: $helloKey"
12754"WbioSrvc start type: $faceConfigured"
12755"FingerPrint key present: $pinConfigured"
12756"#;
12757    match run_powershell(ps_hello) {
12758        Ok(o) => {
12759            for line in o.lines().take(max_entries) {
12760                let l = line.trim();
12761                if !l.is_empty() {
12762                    out.push_str(&format!("- {l}\n"));
12763                }
12764            }
12765        }
12766        Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
12767    }
12768
12769    // Biometric service state
12770    out.push_str("\n=== Biometric service ===\n");
12771    let ps_bio_svc = r#"
12772$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
12773if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
12774else { "WbioSrvc not found" }
12775"#;
12776    match run_powershell(ps_bio_svc) {
12777        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
12778        Err(_) => out.push_str("- Could not query biometric service\n"),
12779    }
12780
12781    // Recent logon failure events
12782    out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
12783    let ps_events = r#"
12784$cutoff = (Get-Date).AddHours(-24)
12785Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
12786ForEach-Object {
12787    $xml = [xml]$_.ToXml()
12788    $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
12789    $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
12790    "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
12791} | Select-Object -First 10
12792"#;
12793    match run_powershell(ps_events) {
12794        Ok(o) if !o.trim().is_empty() => {
12795            let count = o.lines().filter(|l| !l.trim().is_empty()).count();
12796            out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
12797            for line in o.lines().take(max_entries) {
12798                let l = line.trim();
12799                if !l.is_empty() {
12800                    out.push_str(&format!("  {l}\n"));
12801                }
12802            }
12803        }
12804        _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
12805    }
12806
12807    // Credential providers
12808    out.push_str("\n=== Active credential providers ===\n");
12809    let ps_cp = r#"
12810Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
12811ForEach-Object {
12812    $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
12813    if ($name) { $name }
12814} | Select-Object -First 15
12815"#;
12816    match run_powershell(ps_cp) {
12817        Ok(o) if !o.trim().is_empty() => {
12818            for line in o.lines().take(max_entries) {
12819                let l = line.trim();
12820                if !l.is_empty() {
12821                    out.push_str(&format!("- {l}\n"));
12822                }
12823            }
12824        }
12825        _ => out.push_str("- Could not enumerate credential providers\n"),
12826    }
12827
12828    let mut findings: Vec<String> = Vec::new();
12829    if out.contains("WbioSrvc | Status: Stopped") {
12830        findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
12831    }
12832    if out.contains("recent logon failure") && !out.contains("0 recent") {
12833        findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
12834    }
12835
12836    let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
12837    if findings.is_empty() {
12838        result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
12839        result.push_str("  If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
12840    } else {
12841        for f in &findings {
12842            result.push_str(&format!("- Finding: {f}\n"));
12843        }
12844    }
12845    result.push('\n');
12846    result.push_str(&out);
12847    Ok(result)
12848}
12849
12850#[cfg(not(windows))]
12851fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
12852    Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
12853}
12854
12855// ── inspect_installer_health ──────────────────────────────────────────────────
12856
12857#[cfg(windows)]
12858fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
12859    let mut out = String::from("=== Installer engines ===\n");
12860
12861    let ps_engines = r#"
12862$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
12863foreach ($name in $services) {
12864    $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
12865    if ($svc) {
12866        $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
12867        $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
12868        "$name | Status: $($svc.Status) | StartType: $startType"
12869    } else {
12870        "$name | Not present"
12871    }
12872}
12873if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
12874    "msiexec.exe | Present: Yes"
12875} else {
12876    "msiexec.exe | Present: No"
12877}
12878"#;
12879    match run_powershell(ps_engines) {
12880        Ok(o) if !o.trim().is_empty() => {
12881            for line in o.lines().take(max_entries + 6) {
12882                let l = line.trim();
12883                if !l.is_empty() {
12884                    out.push_str(&format!("- {l}\n"));
12885                }
12886            }
12887        }
12888        _ => out.push_str("- Could not inspect installer engine services\n"),
12889    }
12890
12891    out.push_str("\n=== winget and App Installer ===\n");
12892    let ps_winget = r#"
12893$cmd = Get-Command winget -ErrorAction SilentlyContinue
12894if ($cmd) {
12895    try {
12896        $v = & winget --version 2>$null
12897        if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
12898    } catch { "winget | Present but invocation failed" }
12899} else {
12900    "winget | Missing"
12901}
12902$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
12903if ($appInstaller) {
12904    "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
12905} else {
12906    "DesktopAppInstaller | Status: Missing"
12907}
12908"#;
12909    match run_powershell(ps_winget) {
12910        Ok(o) if !o.trim().is_empty() => {
12911            for line in o.lines().take(max_entries) {
12912                let l = line.trim();
12913                if !l.is_empty() {
12914                    out.push_str(&format!("- {l}\n"));
12915                }
12916            }
12917        }
12918        _ => out.push_str("- Could not inspect winget/App Installer state\n"),
12919    }
12920
12921    out.push_str("\n=== Microsoft Store packages ===\n");
12922    let ps_store = r#"
12923$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
12924if ($store) {
12925    "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
12926} else {
12927    "Microsoft.WindowsStore | Status: Missing"
12928}
12929"#;
12930    match run_powershell(ps_store) {
12931        Ok(o) if !o.trim().is_empty() => {
12932            for line in o.lines().take(max_entries) {
12933                let l = line.trim();
12934                if !l.is_empty() {
12935                    out.push_str(&format!("- {l}\n"));
12936                }
12937            }
12938        }
12939        _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
12940    }
12941
12942    out.push_str("\n=== Reboot and transaction blockers ===\n");
12943    let ps_blockers = r#"
12944$pending = $false
12945if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
12946    "RebootPending: CBS"
12947    $pending = $true
12948}
12949if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
12950    "RebootPending: WindowsUpdate"
12951    $pending = $true
12952}
12953$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
12954if ($rename) {
12955    "PendingFileRenameOperations: Yes"
12956    $pending = $true
12957}
12958if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
12959    "InstallerInProgress: Yes"
12960    $pending = $true
12961}
12962if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
12963"#;
12964    match run_powershell(ps_blockers) {
12965        Ok(o) if !o.trim().is_empty() => {
12966            for line in o.lines().take(max_entries) {
12967                let l = line.trim();
12968                if !l.is_empty() {
12969                    out.push_str(&format!("- {l}\n"));
12970                }
12971            }
12972        }
12973        _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
12974    }
12975
12976    out.push_str("\n=== Recent installer failures (7d) ===\n");
12977    let ps_failures = r#"
12978$cutoff = (Get-Date).AddDays(-7)
12979$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
12980    ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
12981$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
12982    Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
12983    Select-Object -First 6 |
12984    ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
12985$all = @($msi) + @($appx)
12986if ($all.Count -eq 0) {
12987    "No recent MSI/AppX installer errors detected"
12988} else {
12989    $all | Select-Object -First 8
12990}
12991"#;
12992    match run_powershell(ps_failures) {
12993        Ok(o) if !o.trim().is_empty() => {
12994            for line in o.lines().take(max_entries + 2) {
12995                let l = line.trim();
12996                if !l.is_empty() {
12997                    out.push_str(&format!("- {l}\n"));
12998                }
12999            }
13000        }
13001        _ => out.push_str("- Could not inspect recent installer failure events\n"),
13002    }
13003
13004    let mut findings: Vec<String> = Vec::new();
13005    if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
13006        findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
13007    }
13008    if out.contains("msiexec.exe | Present: No") {
13009        findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
13010    }
13011    if out.contains("winget | Missing") {
13012        findings.push(
13013            "winget is missing - App Installer may not be installed or registered for this user."
13014                .into(),
13015        );
13016    }
13017    if out.contains("DesktopAppInstaller | Status: Missing") {
13018        findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
13019    }
13020    if out.contains("Microsoft.WindowsStore | Status: Missing") {
13021        findings.push(
13022            "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
13023                .into(),
13024        );
13025    }
13026    if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
13027        findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
13028    }
13029    if out.contains("InstallerInProgress: Yes") {
13030        findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
13031    }
13032    if out.contains("MSI | ") || out.contains("AppX | ") {
13033        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());
13034    }
13035
13036    let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
13037    if findings.is_empty() {
13038        result.push_str("- No obvious installer-platform blocker detected.\n");
13039    } else {
13040        for finding in &findings {
13041            result.push_str(&format!("- Finding: {finding}\n"));
13042        }
13043    }
13044    result.push('\n');
13045    result.push_str(&out);
13046    Ok(result)
13047}
13048
13049#[cfg(not(windows))]
13050fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
13051    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())
13052}
13053
13054// ── inspect_search_index ──────────────────────────────────────────────────────
13055
13056#[cfg(windows)]
13057fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
13058    let mut out = String::from("=== OneDrive client ===\n");
13059
13060    let ps_client = r#"
13061$candidatePaths = @(
13062    (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
13063    (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
13064    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
13065) | Where-Object { $_ -and (Test-Path $_) }
13066$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
13067$exe = $candidatePaths | Select-Object -First 1
13068if (-not $exe -and $proc) {
13069    try { $exe = $proc.Path } catch {}
13070}
13071if ($exe) {
13072    "Installed: Yes"
13073    "Executable: $exe"
13074    try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
13075} else {
13076    "Installed: Unknown"
13077}
13078if ($proc) {
13079    "Process: Running | PID: $($proc.Id)"
13080} else {
13081    "Process: Not running"
13082}
13083"#;
13084    match run_powershell(ps_client) {
13085        Ok(o) if !o.trim().is_empty() => {
13086            for line in o.lines().take(max_entries) {
13087                let l = line.trim();
13088                if !l.is_empty() {
13089                    out.push_str(&format!("- {l}\n"));
13090                }
13091            }
13092        }
13093        _ => out.push_str("- Could not inspect OneDrive client state\n"),
13094    }
13095
13096    out.push_str("\n=== OneDrive accounts ===\n");
13097    let ps_accounts = r#"
13098function MaskEmail([string]$Email) {
13099    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
13100    $parts = $Email.Split('@', 2)
13101    $local = $parts[0]
13102    $domain = $parts[1]
13103    if ($local.Length -le 1) { return "*@$domain" }
13104    return ($local.Substring(0,1) + "***@" + $domain)
13105}
13106$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13107if (Test-Path $base) {
13108    Get-ChildItem $base -ErrorAction SilentlyContinue |
13109        Sort-Object PSChildName |
13110        Select-Object -First 12 |
13111        ForEach-Object {
13112            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13113            $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
13114            $mail = MaskEmail ([string]$p.UserEmail)
13115            $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
13116            $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
13117            "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
13118        }
13119} else {
13120    "No OneDrive accounts configured"
13121}
13122"#;
13123    match run_powershell(ps_accounts) {
13124        Ok(o) if !o.trim().is_empty() => {
13125            for line in o.lines().take(max_entries) {
13126                let l = line.trim();
13127                if !l.is_empty() {
13128                    out.push_str(&format!("- {l}\n"));
13129                }
13130            }
13131        }
13132        _ => out.push_str("- Could not read OneDrive account registry state\n"),
13133    }
13134
13135    out.push_str("\n=== OneDrive policy overrides ===\n");
13136    let ps_policy = r#"
13137$paths = @(
13138    'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
13139    'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
13140)
13141$names = @(
13142    'DisableFileSyncNGSC',
13143    'DisableLibrariesDefaultSaveToOneDrive',
13144    'KFMSilentOptIn',
13145    'KFMBlockOptIn',
13146    'SilentAccountConfig'
13147)
13148$found = $false
13149foreach ($path in $paths) {
13150    if (Test-Path $path) {
13151        $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
13152        foreach ($name in $names) {
13153            $value = $p.$name
13154            if ($null -ne $value -and [string]$value -ne '') {
13155                "$path | $name=$value"
13156                $found = $true
13157            }
13158        }
13159    }
13160}
13161if (-not $found) { "No OneDrive policy overrides detected" }
13162"#;
13163    match run_powershell(ps_policy) {
13164        Ok(o) if !o.trim().is_empty() => {
13165            for line in o.lines().take(max_entries) {
13166                let l = line.trim();
13167                if !l.is_empty() {
13168                    out.push_str(&format!("- {l}\n"));
13169                }
13170            }
13171        }
13172        _ => out.push_str("- Could not read OneDrive policy state\n"),
13173    }
13174
13175    out.push_str("\n=== Known Folder Backup ===\n");
13176    let ps_kfm = r#"
13177$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
13178$roots = @()
13179if (Test-Path $base) {
13180    Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
13181        $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13182        if ($p.UserFolder) {
13183            $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
13184        }
13185    }
13186}
13187$roots = $roots | Select-Object -Unique
13188$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
13189if (Test-Path $shell) {
13190    $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13191    $folders = @(
13192        @{ Name='Desktop'; Value=$props.Desktop },
13193        @{ Name='Documents'; Value=$props.Personal },
13194        @{ Name='Pictures'; Value=$props.'My Pictures' }
13195    )
13196    foreach ($folder in $folders) {
13197        $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13198        if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13199        $protected = $false
13200        foreach ($root in $roots) {
13201            if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13202                $protected = $true
13203                break
13204            }
13205        }
13206        "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13207    }
13208} else {
13209    "Explorer shell folders unavailable"
13210}
13211"#;
13212    match run_powershell(ps_kfm) {
13213        Ok(o) if !o.trim().is_empty() => {
13214            for line in o.lines().take(max_entries) {
13215                let l = line.trim();
13216                if !l.is_empty() {
13217                    out.push_str(&format!("- {l}\n"));
13218                }
13219            }
13220        }
13221        _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13222    }
13223
13224    let mut findings: Vec<String> = Vec::new();
13225    if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13226        findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13227    }
13228    if out.contains("No OneDrive accounts configured") {
13229        findings.push(
13230            "No OneDrive accounts are configured - sync cannot start until the user signs in."
13231                .into(),
13232        );
13233    }
13234    if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13235        findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13236    }
13237    if out.contains("Exists: No") {
13238        findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13239    }
13240    if out.contains("DisableFileSyncNGSC=1") {
13241        findings
13242            .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13243    }
13244    if out.contains("KFMBlockOptIn=1") {
13245        findings
13246            .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13247    }
13248    if out.contains("SyncRoot: C:\\") {
13249        let mut missing_kfm: Vec<&str> = Vec::new();
13250        for folder in ["Desktop", "Documents", "Pictures"] {
13251            if out.lines().any(|line| {
13252                line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13253            }) {
13254                missing_kfm.push(folder);
13255            }
13256        }
13257        if !missing_kfm.is_empty() {
13258            findings.push(format!(
13259                "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13260                missing_kfm.join(", ")
13261            ));
13262        }
13263    }
13264
13265    let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13266    if findings.is_empty() {
13267        result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13268    } else {
13269        for finding in &findings {
13270            result.push_str(&format!("- Finding: {finding}\n"));
13271        }
13272    }
13273    result.push('\n');
13274    result.push_str(&out);
13275    Ok(result)
13276}
13277
13278#[cfg(not(windows))]
13279fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
13280    Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
13281}
13282
13283#[cfg(windows)]
13284fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
13285    let mut out = String::from("=== Browser inventory ===\n");
13286
13287    let ps_inventory = r#"
13288$browsers = @(
13289    @{ Name='Edge'; Paths=@(
13290        (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
13291        (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
13292    ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
13293    @{ Name='Chrome'; Paths=@(
13294        (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
13295        (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
13296        (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
13297    ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
13298    @{ Name='Firefox'; Paths=@(
13299        (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
13300        (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
13301    ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
13302)
13303foreach ($browser in $browsers) {
13304    $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13305    if ($exe) {
13306        $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13307        $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
13308        "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
13309    } else {
13310        "$($browser.Name) | Installed: No"
13311    }
13312}
13313$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13314$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13315$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13316"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
13317"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
13318"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
13319"#;
13320    match run_powershell(ps_inventory) {
13321        Ok(o) if !o.trim().is_empty() => {
13322            for line in o.lines().take(max_entries + 6) {
13323                let l = line.trim();
13324                if !l.is_empty() {
13325                    out.push_str(&format!("- {l}\n"));
13326                }
13327            }
13328        }
13329        _ => out.push_str("- Could not inspect installed browser inventory\n"),
13330    }
13331
13332    out.push_str("\n=== Runtime state ===\n");
13333    let ps_runtime = r#"
13334$targets = 'msedge','chrome','firefox','msedgewebview2'
13335foreach ($name in $targets) {
13336    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13337    if ($procs) {
13338        $count = @($procs).Count
13339        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13340        "$name | Processes: $count | WorkingSetMB: $wsMb"
13341    } else {
13342        "$name | Processes: 0 | WorkingSetMB: 0"
13343    }
13344}
13345"#;
13346    match run_powershell(ps_runtime) {
13347        Ok(o) if !o.trim().is_empty() => {
13348            for line in o.lines().take(max_entries + 4) {
13349                let l = line.trim();
13350                if !l.is_empty() {
13351                    out.push_str(&format!("- {l}\n"));
13352                }
13353            }
13354        }
13355        _ => out.push_str("- Could not inspect browser runtime state\n"),
13356    }
13357
13358    out.push_str("\n=== WebView2 runtime ===\n");
13359    let ps_webview = r#"
13360$paths = @(
13361    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13362    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13363) | Where-Object { $_ -and (Test-Path $_) }
13364$runtimeDir = $paths | ForEach-Object {
13365    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13366        Where-Object { $_.Name -match '^\d+\.' } |
13367        Sort-Object Name -Descending |
13368        Select-Object -First 1
13369} | Select-Object -First 1
13370if ($runtimeDir) {
13371    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
13372    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
13373    "Installed: Yes"
13374    "Version: $version"
13375    "Executable: $exe"
13376} else {
13377    "Installed: No"
13378}
13379$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
13380"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
13381"#;
13382    match run_powershell(ps_webview) {
13383        Ok(o) if !o.trim().is_empty() => {
13384            for line in o.lines().take(max_entries) {
13385                let l = line.trim();
13386                if !l.is_empty() {
13387                    out.push_str(&format!("- {l}\n"));
13388                }
13389            }
13390        }
13391        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
13392    }
13393
13394    out.push_str("\n=== Policy and proxy surface ===\n");
13395    let ps_policy = r#"
13396$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
13397$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
13398$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
13399$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
13400$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
13401"UserProxyEnabled: $proxyEnabled"
13402"UserProxyServer: $proxyServer"
13403"UserAutoConfigURL: $autoConfig"
13404"UserAutoDetect: $autoDetect"
13405$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
13406if ($winhttp) {
13407    $normalized = ($winhttp -replace '\s+', ' ').Trim()
13408    $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
13409    "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
13410    "WinHTTP: $normalized"
13411}
13412$policyTargets = @(
13413    @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
13414    @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
13415)
13416foreach ($policy in $policyTargets) {
13417    if (Test-Path $policy.Path) {
13418        $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
13419        foreach ($key in $policy.Keys) {
13420            $value = $item.$key
13421            if ($null -ne $value -and [string]$value -ne '') {
13422                if ($value -is [array]) {
13423                    "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
13424                } else {
13425                    "$($policy.Name)Policy | $key=$value"
13426                }
13427            }
13428        }
13429    }
13430}
13431"#;
13432    match run_powershell(ps_policy) {
13433        Ok(o) if !o.trim().is_empty() => {
13434            for line in o.lines().take(max_entries + 8) {
13435                let l = line.trim();
13436                if !l.is_empty() {
13437                    out.push_str(&format!("- {l}\n"));
13438                }
13439            }
13440        }
13441        _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
13442    }
13443
13444    out.push_str("\n=== Profile and cache pressure ===\n");
13445    let ps_profiles = r#"
13446$profiles = @(
13447    @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
13448    @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
13449    @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
13450)
13451foreach ($profile in $profiles) {
13452    if (Test-Path $profile.Root) {
13453        if ($profile.Name -eq 'Firefox') {
13454            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
13455        } else {
13456            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
13457                Where-Object {
13458                    $_.Name -eq 'Default' -or
13459                    $_.Name -eq 'Guest Profile' -or
13460                    $_.Name -eq 'System Profile' -or
13461                    $_.Name -like 'Profile *'
13462                }
13463        }
13464        $profileCount = @($dirs).Count
13465        $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
13466        if (-not $sizeBytes) { $sizeBytes = 0 }
13467        $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
13468        $extCount = 'Unknown'
13469        if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
13470            $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
13471        }
13472        "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
13473    } else {
13474        "$($profile.Name) | ProfileRoot: Missing"
13475    }
13476}
13477"#;
13478    match run_powershell(ps_profiles) {
13479        Ok(o) if !o.trim().is_empty() => {
13480            for line in o.lines().take(max_entries + 4) {
13481                let l = line.trim();
13482                if !l.is_empty() {
13483                    out.push_str(&format!("- {l}\n"));
13484                }
13485            }
13486        }
13487        _ => out.push_str("- Could not inspect browser profile pressure\n"),
13488    }
13489
13490    out.push_str("\n=== Recent browser failures (7d) ===\n");
13491    let ps_failures = r#"
13492$cutoff = (Get-Date).AddDays(-7)
13493$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
13494$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
13495    Where-Object {
13496        $msg = [string]$_.Message
13497        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
13498        ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
13499    } |
13500    Select-Object -First 6
13501if ($events) {
13502    foreach ($event in $events) {
13503        $msg = ($event.Message -replace '\s+', ' ')
13504        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
13505        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
13506    }
13507} else {
13508    "No recent browser crash or WER events detected"
13509}
13510"#;
13511    match run_powershell(ps_failures) {
13512        Ok(o) if !o.trim().is_empty() => {
13513            for line in o.lines().take(max_entries + 2) {
13514                let l = line.trim();
13515                if !l.is_empty() {
13516                    out.push_str(&format!("- {l}\n"));
13517                }
13518            }
13519        }
13520        _ => out.push_str("- Could not inspect recent browser failure events\n"),
13521    }
13522
13523    let mut findings: Vec<String> = Vec::new();
13524    if out.contains("Edge | Installed: No")
13525        && out.contains("Chrome | Installed: No")
13526        && out.contains("Firefox | Installed: No")
13527    {
13528        findings.push(
13529            "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
13530                .into(),
13531        );
13532    }
13533    if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
13534        findings.push(
13535            "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
13536                .into(),
13537        );
13538    }
13539    if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
13540        findings.push(
13541            "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
13542                .into(),
13543        );
13544    }
13545    if out.contains("EdgePolicy | Proxy")
13546        || out.contains("ChromePolicy | Proxy")
13547        || out.contains("ExtensionInstallForcelist=")
13548    {
13549        findings.push(
13550            "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
13551                .into(),
13552        );
13553    }
13554    for browser in ["msedge", "chrome", "firefox"] {
13555        let process_marker = format!("{browser} | Processes: ");
13556        if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
13557            let count = line
13558                .split("| Processes: ")
13559                .nth(1)
13560                .and_then(|rest| rest.split(" |").next())
13561                .and_then(|value| value.trim().parse::<usize>().ok())
13562                .unwrap_or(0);
13563            let ws_mb = line
13564                .split("| WorkingSetMB: ")
13565                .nth(1)
13566                .and_then(|value| value.trim().parse::<f64>().ok())
13567                .unwrap_or(0.0);
13568            if count >= 25 {
13569                findings.push(format!(
13570                    "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
13571                ));
13572            } else if ws_mb >= 2500.0 {
13573                findings.push(format!(
13574                    "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
13575                ));
13576            }
13577        }
13578    }
13579    if out.contains("=== WebView2 runtime ===\n- Installed: No")
13580        || (out.contains("=== WebView2 runtime ===")
13581            && out.contains("- Installed: No")
13582            && out.contains("- ProcessCount: 0"))
13583    {
13584        findings.push(
13585            "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
13586                .into(),
13587        );
13588    }
13589    for browser in ["Edge", "Chrome", "Firefox"] {
13590        let prefix = format!("{browser} | ProfileRoot:");
13591        if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
13592            let size_gb = line
13593                .split("| SizeGB: ")
13594                .nth(1)
13595                .and_then(|rest| rest.split(" |").next())
13596                .and_then(|value| value.trim().parse::<f64>().ok())
13597                .unwrap_or(0.0);
13598            let ext_count = line
13599                .split("| Extensions: ")
13600                .nth(1)
13601                .and_then(|value| value.trim().parse::<usize>().ok())
13602                .unwrap_or(0);
13603            if size_gb >= 2.5 {
13604                findings.push(format!(
13605                    "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
13606                ));
13607            }
13608            if ext_count >= 20 {
13609                findings.push(format!(
13610                    "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
13611                ));
13612            }
13613        }
13614    }
13615    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
13616        findings.push(
13617            "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
13618                .into(),
13619        );
13620    }
13621
13622    let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
13623    if findings.is_empty() {
13624        result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
13625    } else {
13626        for finding in &findings {
13627            result.push_str(&format!("- Finding: {finding}\n"));
13628        }
13629    }
13630    result.push('\n');
13631    result.push_str(&out);
13632    Ok(result)
13633}
13634
13635#[cfg(not(windows))]
13636fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
13637    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())
13638}
13639
13640#[cfg(windows)]
13641fn inspect_outlook(max_entries: usize) -> Result<String, String> {
13642    let mut out = String::from("=== Outlook install inventory ===\n");
13643
13644    let ps_install = r#"
13645$installPaths = @(
13646    (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
13647    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
13648    (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
13649    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
13650    (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
13651    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
13652)
13653$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13654if ($exe) {
13655    $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13656    $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
13657    "Installed: Yes"
13658    "Executable: $exe"
13659    "Version: $version"
13660    "Product: $productName"
13661} else {
13662    "Installed: No"
13663}
13664$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
13665if ($newOutlook) {
13666    "NewOutlook: Installed | Version: $($newOutlook.Version)"
13667} else {
13668    "NewOutlook: Not installed"
13669}
13670"#;
13671    match run_powershell(ps_install) {
13672        Ok(o) if !o.trim().is_empty() => {
13673            for line in o.lines().take(max_entries + 4) {
13674                let l = line.trim();
13675                if !l.is_empty() {
13676                    out.push_str(&format!("- {l}\n"));
13677                }
13678            }
13679        }
13680        _ => out.push_str("- Could not inspect Outlook install paths\n"),
13681    }
13682
13683    out.push_str("\n=== Runtime state ===\n");
13684    let ps_runtime = r#"
13685$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
13686if ($proc) {
13687    $count = @($proc).Count
13688    $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13689    $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
13690    "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
13691} else {
13692    "Running: No"
13693}
13694"#;
13695    match run_powershell(ps_runtime) {
13696        Ok(o) if !o.trim().is_empty() => {
13697            for line in o.lines().take(4) {
13698                let l = line.trim();
13699                if !l.is_empty() {
13700                    out.push_str(&format!("- {l}\n"));
13701                }
13702            }
13703        }
13704        _ => out.push_str("- Could not inspect Outlook runtime state\n"),
13705    }
13706
13707    out.push_str("\n=== Mail profiles ===\n");
13708    let ps_profiles = r#"
13709$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
13710if (-not (Test-Path $profileKey)) {
13711    $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
13712}
13713if (Test-Path $profileKey) {
13714    $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
13715    $count = @($profiles).Count
13716    "ProfileCount: $count"
13717    foreach ($p in $profiles | Select-Object -First 10) {
13718        "Profile: $($p.PSChildName)"
13719    }
13720} else {
13721    "ProfileCount: 0"
13722    "No Outlook profiles found in registry"
13723}
13724"#;
13725    match run_powershell(ps_profiles) {
13726        Ok(o) if !o.trim().is_empty() => {
13727            for line in o.lines().take(max_entries + 2) {
13728                let l = line.trim();
13729                if !l.is_empty() {
13730                    out.push_str(&format!("- {l}\n"));
13731                }
13732            }
13733        }
13734        _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
13735    }
13736
13737    out.push_str("\n=== OST and PST data files ===\n");
13738    let ps_datafiles = r#"
13739$searchRoots = @(
13740    (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
13741    (Join-Path $env:USERPROFILE 'Documents'),
13742    (Join-Path $env:USERPROFILE 'OneDrive\Documents')
13743) | Where-Object { $_ -and (Test-Path $_) }
13744$files = foreach ($root in $searchRoots) {
13745    Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
13746        Select-Object FullName,
13747            @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
13748            @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
13749            LastWriteTime
13750}
13751if ($files) {
13752    foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
13753        "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
13754    }
13755} else {
13756    "No OST or PST files found in standard locations"
13757}
13758"#;
13759    match run_powershell(ps_datafiles) {
13760        Ok(o) if !o.trim().is_empty() => {
13761            for line in o.lines().take(max_entries + 4) {
13762                let l = line.trim();
13763                if !l.is_empty() {
13764                    out.push_str(&format!("- {l}\n"));
13765                }
13766            }
13767        }
13768        _ => out.push_str("- Could not inspect OST/PST data files\n"),
13769    }
13770
13771    out.push_str("\n=== Add-in pressure ===\n");
13772    let ps_addins = r#"
13773$addinPaths = @(
13774    'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
13775    'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
13776    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
13777)
13778$addins = foreach ($path in $addinPaths) {
13779    if (Test-Path $path) {
13780        Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
13781            $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13782            $loadBehavior = $item.LoadBehavior
13783            $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
13784            [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
13785        }
13786    }
13787}
13788$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
13789$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
13790"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
13791foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
13792    $state = switch ($a.LoadBehavior) {
13793        0 { 'Disabled' }
13794        2 { 'LoadOnStart(inactive)' }
13795        3 { 'ActiveOnStart' }
13796        8 { 'DemandLoad' }
13797        9 { 'ActiveDemand' }
13798        16 { 'ConnectedFirst' }
13799        default { "LoadBehavior=$($a.LoadBehavior)" }
13800    }
13801    "$($a.Name) | $state"
13802}
13803$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
13804$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
13805if (Test-Path $disabledByResiliency) {
13806    $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
13807    $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
13808    if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
13809}
13810"#;
13811    match run_powershell(ps_addins) {
13812        Ok(o) if !o.trim().is_empty() => {
13813            for line in o.lines().take(max_entries + 8) {
13814                let l = line.trim();
13815                if !l.is_empty() {
13816                    out.push_str(&format!("- {l}\n"));
13817                }
13818            }
13819        }
13820        _ => out.push_str("- Could not inspect Outlook add-ins\n"),
13821    }
13822
13823    out.push_str("\n=== Authentication and cache friction ===\n");
13824    let ps_auth = r#"
13825$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
13826$tokenCount = if (Test-Path $tokenCache) {
13827    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
13828} else { 0 }
13829"TokenBrokerCacheFiles: $tokenCount"
13830$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
13831$credsCount = @($credentialManager).Count
13832"OfficeCredentialsInVault: $credsCount"
13833$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
13834if (Test-Path $samlKey) {
13835    $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
13836    $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
13837    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
13838    "WAMOverride: $connected"
13839    "SignedInUserId: $signedIn"
13840}
13841$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
13842if (Test-Path $outlookReg) {
13843    $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
13844    if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
13845}
13846"#;
13847    match run_powershell(ps_auth) {
13848        Ok(o) if !o.trim().is_empty() => {
13849            for line in o.lines().take(max_entries + 4) {
13850                let l = line.trim();
13851                if !l.is_empty() {
13852                    out.push_str(&format!("- {l}\n"));
13853                }
13854            }
13855        }
13856        _ => out.push_str("- Could not inspect Outlook auth state\n"),
13857    }
13858
13859    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
13860    let ps_events = r#"
13861$cutoff = (Get-Date).AddDays(-7)
13862$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
13863    Where-Object {
13864        $msg = [string]$_.Message
13865        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
13866        ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
13867    } |
13868    Select-Object -First 8
13869if ($events) {
13870    foreach ($event in $events) {
13871        $msg = ($event.Message -replace '\s+', ' ')
13872        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
13873        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
13874    }
13875} else {
13876    "No recent Outlook crash or error events detected in Application log"
13877}
13878"#;
13879    match run_powershell(ps_events) {
13880        Ok(o) if !o.trim().is_empty() => {
13881            for line in o.lines().take(max_entries + 4) {
13882                let l = line.trim();
13883                if !l.is_empty() {
13884                    out.push_str(&format!("- {l}\n"));
13885                }
13886            }
13887        }
13888        _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
13889    }
13890
13891    let mut findings: Vec<String> = Vec::new();
13892
13893    if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
13894        findings.push(
13895            "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
13896                .into(),
13897        );
13898    }
13899
13900    if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
13901        let ws_mb = line
13902            .split("WorkingSetMB: ")
13903            .nth(1)
13904            .and_then(|r| r.split(" |").next())
13905            .and_then(|v| v.trim().parse::<f64>().ok())
13906            .unwrap_or(0.0);
13907        if ws_mb >= 1500.0 {
13908            findings.push(format!(
13909                "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
13910            ));
13911        }
13912    }
13913
13914    let large_ost: Vec<String> = out
13915        .lines()
13916        .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
13917        .filter_map(|l| {
13918            let mb = l
13919                .split("SizeMB: ")
13920                .nth(1)
13921                .and_then(|r| r.split(" |").next())
13922                .and_then(|v| v.trim().parse::<f64>().ok())
13923                .unwrap_or(0.0);
13924            if mb >= 10_000.0 {
13925                Some(format!("{mb:.0} MB OST file detected"))
13926            } else {
13927                None
13928            }
13929        })
13930        .collect();
13931    for msg in large_ost {
13932        findings.push(format!(
13933            "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
13934        ));
13935    }
13936
13937    if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
13938        let active_count = line
13939            .split("Active: ")
13940            .nth(1)
13941            .and_then(|r| r.split(" |").next())
13942            .and_then(|v| v.trim().parse::<usize>().ok())
13943            .unwrap_or(0);
13944        if active_count >= 8 {
13945            findings.push(format!(
13946                "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
13947            ));
13948        }
13949    }
13950
13951    if out.contains("ResiliencyDisabledItems:") {
13952        findings.push(
13953            "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
13954                .into(),
13955        );
13956    }
13957
13958    if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
13959        findings.push(
13960            "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
13961                .into(),
13962        );
13963    }
13964
13965    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
13966        findings.push(
13967            "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)."
13968                .into(),
13969        );
13970    }
13971
13972    let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
13973    if findings.is_empty() {
13974        result.push_str("- No obvious Outlook health blocker detected.\n");
13975    } else {
13976        for finding in &findings {
13977            result.push_str(&format!("- Finding: {finding}\n"));
13978        }
13979    }
13980    result.push('\n');
13981    result.push_str(&out);
13982    Ok(result)
13983}
13984
13985#[cfg(not(windows))]
13986fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
13987    Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
13988}
13989
13990#[cfg(windows)]
13991fn inspect_teams(max_entries: usize) -> Result<String, String> {
13992    let mut out = String::from("=== Teams install inventory ===\n");
13993
13994    let ps_install = r#"
13995# Classic Teams (Teams 1.0)
13996$classicExe = @(
13997    (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
13998    (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
13999) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14000
14001if ($classicExe) {
14002    $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
14003    "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
14004} else {
14005    "ClassicTeams: Not installed"
14006}
14007
14008# New Teams (Teams 2.0 / ms-teams.exe)
14009$newTeamsExe = @(
14010    (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
14011    (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
14012) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
14013
14014$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
14015if ($newTeamsPkg) {
14016    "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
14017} elseif ($newTeamsExe) {
14018    $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
14019    "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
14020} else {
14021    "NewTeams: Not installed"
14022}
14023
14024# Teams Machine-Wide Installer (MSI/per-machine)
14025$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
14026    Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
14027    Select-Object -First 1
14028if ($mwi) {
14029    "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
14030} else {
14031    "MachineWideInstaller: Not found"
14032}
14033"#;
14034    match run_powershell(ps_install) {
14035        Ok(o) if !o.trim().is_empty() => {
14036            for line in o.lines().take(max_entries + 4) {
14037                let l = line.trim();
14038                if !l.is_empty() {
14039                    out.push_str(&format!("- {l}\n"));
14040                }
14041            }
14042        }
14043        _ => out.push_str("- Could not inspect Teams install paths\n"),
14044    }
14045
14046    out.push_str("\n=== Runtime state ===\n");
14047    let ps_runtime = r#"
14048$targets = @('Teams','ms-teams')
14049foreach ($name in $targets) {
14050    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
14051    if ($procs) {
14052        $count = @($procs).Count
14053        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
14054        "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
14055    } else {
14056        "$name | Running: No"
14057    }
14058}
14059"#;
14060    match run_powershell(ps_runtime) {
14061        Ok(o) if !o.trim().is_empty() => {
14062            for line in o.lines().take(6) {
14063                let l = line.trim();
14064                if !l.is_empty() {
14065                    out.push_str(&format!("- {l}\n"));
14066                }
14067            }
14068        }
14069        _ => out.push_str("- Could not inspect Teams runtime state\n"),
14070    }
14071
14072    out.push_str("\n=== Cache directory sizing ===\n");
14073    let ps_cache = r#"
14074$cachePaths = @(
14075    @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
14076    @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
14077    @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
14078    @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
14079)
14080foreach ($entry in $cachePaths) {
14081    if (Test-Path $entry.Path) {
14082        $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
14083        if (-not $sizeBytes) { $sizeBytes = 0 }
14084        $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
14085        "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
14086    } else {
14087        "$($entry.Name) | Path: $($entry.Path) | Not found"
14088    }
14089}
14090"#;
14091    match run_powershell(ps_cache) {
14092        Ok(o) if !o.trim().is_empty() => {
14093            for line in o.lines().take(max_entries + 4) {
14094                let l = line.trim();
14095                if !l.is_empty() {
14096                    out.push_str(&format!("- {l}\n"));
14097                }
14098            }
14099        }
14100        _ => out.push_str("- Could not inspect Teams cache directories\n"),
14101    }
14102
14103    out.push_str("\n=== WebView2 runtime ===\n");
14104    let ps_webview = r#"
14105$paths = @(
14106    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14107    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14108) | Where-Object { $_ -and (Test-Path $_) }
14109$runtimeDir = $paths | ForEach-Object {
14110    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14111        Where-Object { $_.Name -match '^\d+\.' } |
14112        Sort-Object Name -Descending |
14113        Select-Object -First 1
14114} | Select-Object -First 1
14115if ($runtimeDir) {
14116    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14117    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14118    "Installed: Yes | Version: $version"
14119} else {
14120    "Installed: No -- New Teams and some Office features require WebView2"
14121}
14122"#;
14123    match run_powershell(ps_webview) {
14124        Ok(o) if !o.trim().is_empty() => {
14125            for line in o.lines().take(4) {
14126                let l = line.trim();
14127                if !l.is_empty() {
14128                    out.push_str(&format!("- {l}\n"));
14129                }
14130            }
14131        }
14132        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14133    }
14134
14135    out.push_str("\n=== Account and sign-in state ===\n");
14136    let ps_auth = r#"
14137# Classic Teams account registry
14138$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14139if (Test-Path $classicAcct) {
14140    $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
14141    $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
14142    "ClassicTeamsAccount: $email"
14143} else {
14144    "ClassicTeamsAccount: Not configured"
14145}
14146# WAM / token broker state for Teams
14147$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14148$tokenCount = if (Test-Path $tokenCache) {
14149    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
14150} else { 0 }
14151"TokenBrokerCacheFiles: $tokenCount"
14152# Office identity
14153$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14154if (Test-Path $officeId) {
14155    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14156    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
14157    "OfficeSignedInUserId: $signedIn"
14158}
14159# Check if Teams is in startup
14160$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
14161$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
14162"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
14163"#;
14164    match run_powershell(ps_auth) {
14165        Ok(o) if !o.trim().is_empty() => {
14166            for line in o.lines().take(max_entries + 4) {
14167                let l = line.trim();
14168                if !l.is_empty() {
14169                    out.push_str(&format!("- {l}\n"));
14170                }
14171            }
14172        }
14173        _ => out.push_str("- Could not inspect Teams account state\n"),
14174    }
14175
14176    out.push_str("\n=== Audio and video device binding ===\n");
14177    let ps_devices = r#"
14178# Teams stores device prefs in the settings file
14179$settingsPaths = @(
14180    (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
14181    (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
14182)
14183$found = $false
14184foreach ($sp in $settingsPaths) {
14185    if (Test-Path $sp) {
14186        $found = $true
14187        $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
14188        if ($raw) {
14189            $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14190            if ($json) {
14191                $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14192                $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14193                $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14194                "ConfigFile: $sp"
14195                "Microphone: $mic"
14196                "Speaker: $spk"
14197                "Camera: $cam"
14198            } else {
14199                "ConfigFile: $sp (not parseable as JSON)"
14200            }
14201        } else {
14202            "ConfigFile: $sp (empty)"
14203        }
14204        break
14205    }
14206}
14207if (-not $found) {
14208    "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14209}
14210"#;
14211    match run_powershell(ps_devices) {
14212        Ok(o) if !o.trim().is_empty() => {
14213            for line in o.lines().take(max_entries + 4) {
14214                let l = line.trim();
14215                if !l.is_empty() {
14216                    out.push_str(&format!("- {l}\n"));
14217                }
14218            }
14219        }
14220        _ => out.push_str("- Could not inspect Teams device binding\n"),
14221    }
14222
14223    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14224    let ps_events = r#"
14225$cutoff = (Get-Date).AddDays(-7)
14226$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14227    Where-Object {
14228        $msg = [string]$_.Message
14229        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14230        ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14231    } |
14232    Select-Object -First 8
14233if ($events) {
14234    foreach ($event in $events) {
14235        $msg = ($event.Message -replace '\s+', ' ')
14236        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14237        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14238    }
14239} else {
14240    "No recent Teams crash or error events detected in Application log"
14241}
14242"#;
14243    match run_powershell(ps_events) {
14244        Ok(o) if !o.trim().is_empty() => {
14245            for line in o.lines().take(max_entries + 4) {
14246                let l = line.trim();
14247                if !l.is_empty() {
14248                    out.push_str(&format!("- {l}\n"));
14249                }
14250            }
14251        }
14252        _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14253    }
14254
14255    let mut findings: Vec<String> = Vec::new();
14256
14257    let classic_installed = out.contains("- ClassicTeams: Installed");
14258    let new_installed = out.contains("- NewTeams: Installed");
14259    if !classic_installed && !new_installed {
14260        findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14261    }
14262
14263    for name in ["Teams", "ms-teams"] {
14264        let marker = format!("{name} | Running: Yes | Processes:");
14265        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14266            let ws_mb = line
14267                .split("WorkingSetMB: ")
14268                .nth(1)
14269                .and_then(|v| v.trim().parse::<f64>().ok())
14270                .unwrap_or(0.0);
14271            if ws_mb >= 1000.0 {
14272                findings.push(format!(
14273                    "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
14274                ));
14275            }
14276        }
14277    }
14278
14279    for (label, threshold_mb) in [
14280        ("ClassicTeamsCache", 500.0_f64),
14281        ("ClassicTeamsSquirrel", 2000.0),
14282        ("NewTeamsCache", 500.0),
14283        ("NewTeamsAppData", 3000.0),
14284    ] {
14285        let marker = format!("{label} |");
14286        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14287            let mb = line
14288                .split("SizeMB: ")
14289                .nth(1)
14290                .and_then(|v| v.trim().parse::<f64>().ok())
14291                .unwrap_or(0.0);
14292            if mb >= threshold_mb {
14293                findings.push(format!(
14294                    "{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."
14295                ));
14296            }
14297        }
14298    }
14299
14300    if out.contains("- Installed: No -- New Teams") {
14301        findings.push(
14302            "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
14303                .into(),
14304        );
14305    }
14306
14307    if out.contains("- ClassicTeamsAccount: Not configured")
14308        && out.contains("- OfficeSignedInUserId: None")
14309    {
14310        findings.push(
14311            "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
14312                .into(),
14313        );
14314    }
14315
14316    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14317        findings.push(
14318            "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
14319                .into(),
14320        );
14321    }
14322
14323    let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
14324    if findings.is_empty() {
14325        result.push_str("- No obvious Teams health blocker detected.\n");
14326    } else {
14327        for finding in &findings {
14328            result.push_str(&format!("- Finding: {finding}\n"));
14329        }
14330    }
14331    result.push('\n');
14332    result.push_str(&out);
14333    Ok(result)
14334}
14335
14336#[cfg(not(windows))]
14337fn inspect_teams(_max_entries: usize) -> Result<String, String> {
14338    Ok(
14339        "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
14340            .into(),
14341    )
14342}
14343
14344#[cfg(windows)]
14345fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
14346    let mut out = String::from("=== Identity broker services ===\n");
14347
14348    let ps_services = r#"
14349$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
14350foreach ($name in $serviceNames) {
14351    $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14352    if ($svc) {
14353        "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
14354    } else {
14355        "$name | Not found"
14356    }
14357}
14358"#;
14359    match run_powershell(ps_services) {
14360        Ok(o) if !o.trim().is_empty() => {
14361            for line in o.lines().take(max_entries) {
14362                let l = line.trim();
14363                if !l.is_empty() {
14364                    out.push_str(&format!("- {l}\n"));
14365                }
14366            }
14367        }
14368        _ => out.push_str("- Could not inspect identity broker services\n"),
14369    }
14370
14371    out.push_str("\n=== Device registration ===\n");
14372    let ps_device = r#"
14373$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
14374if ($dsreg) {
14375    try {
14376        $raw = & $dsreg.Source /status 2>$null
14377        $text = ($raw -join "`n")
14378        $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
14379        $seen = $false
14380        foreach ($key in $keys) {
14381            $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
14382            if ($match.Success) {
14383                "${key}: $($match.Groups[1].Value.Trim())"
14384                $seen = $true
14385            }
14386        }
14387        if (-not $seen) {
14388            "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
14389        }
14390    } catch {
14391        "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
14392    }
14393} else {
14394    "DeviceRegistration: dsregcmd unavailable"
14395}
14396"#;
14397    match run_powershell(ps_device) {
14398        Ok(o) if !o.trim().is_empty() => {
14399            for line in o.lines().take(max_entries + 4) {
14400                let l = line.trim();
14401                if !l.is_empty() {
14402                    out.push_str(&format!("- {l}\n"));
14403                }
14404            }
14405        }
14406        _ => out.push_str(
14407            "- DeviceRegistration: Could not inspect device registration state in this session\n",
14408        ),
14409    }
14410
14411    out.push_str("\n=== Broker packages and caches ===\n");
14412    let ps_broker = r#"
14413$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
14414if ($pkg) {
14415    "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
14416} else {
14417    "AADBrokerPlugin: Not installed"
14418}
14419$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14420$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14421"TokenBrokerCacheFiles: $tokenCount"
14422$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
14423$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14424"IdentityCacheFiles: $identityCount"
14425$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
14426$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14427"OneAuthFiles: $oneAuthCount"
14428"#;
14429    match run_powershell(ps_broker) {
14430        Ok(o) if !o.trim().is_empty() => {
14431            for line in o.lines().take(max_entries + 4) {
14432                let l = line.trim();
14433                if !l.is_empty() {
14434                    out.push_str(&format!("- {l}\n"));
14435                }
14436            }
14437        }
14438        _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
14439    }
14440
14441    out.push_str("\n=== Microsoft app account signals ===\n");
14442    let ps_accounts = r#"
14443function MaskEmail([string]$Email) {
14444    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
14445    $parts = $Email.Split('@', 2)
14446    $local = $parts[0]
14447    $domain = $parts[1]
14448    if ($local.Length -le 1) { return "*@$domain" }
14449    return ($local.Substring(0,1) + "***@" + $domain)
14450}
14451$allAccounts = @()
14452$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14453if (Test-Path $officeId) {
14454    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14455    if ($id.SignedInUserId) {
14456        $allAccounts += [string]$id.SignedInUserId
14457        "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
14458    } else {
14459        "OfficeSignedInUserId: None"
14460    }
14461} else {
14462    "OfficeSignedInUserId: Not configured"
14463}
14464$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14465if (Test-Path $teamsAcct) {
14466    $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
14467    $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
14468    if (-not [string]::IsNullOrWhiteSpace($email)) {
14469        $allAccounts += $email
14470        "TeamsAccount: $(MaskEmail $email)"
14471    } else {
14472        "TeamsAccount: Unknown"
14473    }
14474} else {
14475    "TeamsAccount: Not configured"
14476}
14477$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
14478$oneDriveEmails = @()
14479if (Test-Path $oneDriveBase) {
14480    $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
14481        ForEach-Object {
14482            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14483            if ($p.UserEmail) { [string]$p.UserEmail }
14484        } |
14485        Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
14486        Sort-Object -Unique
14487}
14488$allAccounts += $oneDriveEmails
14489"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
14490if (@($oneDriveEmails).Count -gt 0) {
14491    "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14492}
14493$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
14494"DistinctIdentityCount: $($distinct.Count)"
14495if ($distinct.Count -gt 0) {
14496    "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14497}
14498"#;
14499    match run_powershell(ps_accounts) {
14500        Ok(o) if !o.trim().is_empty() => {
14501            for line in o.lines().take(max_entries + 6) {
14502                let l = line.trim();
14503                if !l.is_empty() {
14504                    out.push_str(&format!("- {l}\n"));
14505                }
14506            }
14507        }
14508        _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
14509    }
14510
14511    out.push_str("\n=== WebView2 auth dependency ===\n");
14512    let ps_webview = r#"
14513$paths = @(
14514    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14515    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14516) | Where-Object { $_ -and (Test-Path $_) }
14517$runtimeDir = $paths | ForEach-Object {
14518    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14519        Where-Object { $_.Name -match '^\d+\.' } |
14520        Sort-Object Name -Descending |
14521        Select-Object -First 1
14522} | Select-Object -First 1
14523if ($runtimeDir) {
14524    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14525    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14526    "WebView2: Installed | Version: $version"
14527} else {
14528    "WebView2: Not installed"
14529}
14530"#;
14531    match run_powershell(ps_webview) {
14532        Ok(o) if !o.trim().is_empty() => {
14533            for line in o.lines().take(4) {
14534                let l = line.trim();
14535                if !l.is_empty() {
14536                    out.push_str(&format!("- {l}\n"));
14537                }
14538            }
14539        }
14540        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14541    }
14542
14543    out.push_str("\n=== Recent auth-related events (24h) ===\n");
14544    let ps_events = r#"
14545try {
14546    $cutoff = (Get-Date).AddHours(-24)
14547    $events = @()
14548    if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
14549        $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
14550            Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
14551            Select-Object -First 4
14552    }
14553    $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
14554        Where-Object {
14555            ($_.LevelDisplayName -in @('Error','Warning')) -and (
14556                $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
14557                -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
14558            )
14559        } |
14560        Select-Object -First 6
14561    $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
14562    "AuthEventCount: $(@($events).Count)"
14563    if ($events) {
14564        foreach ($e in $events) {
14565            $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
14566                'No message'
14567            } else {
14568                ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
14569            }
14570            "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
14571        }
14572    } else {
14573        "No auth-related warning/error events detected"
14574    }
14575} catch {
14576    "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
14577}
14578"#;
14579    match run_powershell(ps_events) {
14580        Ok(o) if !o.trim().is_empty() => {
14581            for line in o.lines().take(max_entries + 8) {
14582                let l = line.trim();
14583                if !l.is_empty() {
14584                    out.push_str(&format!("- {l}\n"));
14585                }
14586            }
14587        }
14588        _ => out
14589            .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
14590    }
14591
14592    let parse_count = |prefix: &str| -> Option<u64> {
14593        out.lines().find_map(|line| {
14594            line.trim()
14595                .strip_prefix(prefix)
14596                .and_then(|value| value.trim().parse::<u64>().ok())
14597        })
14598    };
14599
14600    let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
14601    let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
14602
14603    let mut findings: Vec<String> = Vec::new();
14604    if out.contains("TokenBroker | Status: Stopped")
14605        || out.contains("wlidsvc | Status: Stopped")
14606        || out.contains("OneAuth | Status: Stopped")
14607    {
14608        findings.push(
14609            "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."
14610                .into(),
14611        );
14612    }
14613    if out.contains("AADBrokerPlugin: Not installed") {
14614        findings.push(
14615            "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
14616                .into(),
14617        );
14618    }
14619    if out.contains("WebView2: Not installed") {
14620        findings.push(
14621            "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
14622                .into(),
14623        );
14624    }
14625    if distinct_identity_count > 1 {
14626        findings.push(format!(
14627            "{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."
14628        ));
14629    }
14630    if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
14631        && distinct_identity_count > 0
14632    {
14633        findings.push(
14634            "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
14635                .into(),
14636        );
14637    }
14638    if out.contains("DeviceRegistration: dsregcmd")
14639        || out.contains("DeviceRegistration: Could not inspect device registration state")
14640    {
14641        findings.push(
14642            "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."
14643                .into(),
14644        );
14645    }
14646    if auth_event_count > 0 {
14647        findings.push(format!(
14648            "{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."
14649        ));
14650    } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
14651        findings.push(
14652            "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."
14653                .into(),
14654        );
14655    }
14656
14657    let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
14658    if findings.is_empty() {
14659        result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
14660    } else {
14661        for finding in &findings {
14662            result.push_str(&format!("- Finding: {finding}\n"));
14663        }
14664    }
14665    result.push('\n');
14666    result.push_str(&out);
14667    Ok(result)
14668}
14669
14670#[cfg(not(windows))]
14671fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
14672    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())
14673}
14674
14675#[cfg(windows)]
14676fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
14677    let mut out = String::from("=== File History ===\n");
14678
14679    let ps_fh = r#"
14680$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
14681if ($svc) {
14682    "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
14683} else {
14684    "FileHistoryService: Not found"
14685}
14686# File History config in registry
14687$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
14688$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
14689if (Test-Path $fhUser) {
14690    $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
14691    $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
14692    $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
14693    $lastBackup = if ($fh.ProtectedUpToTime) {
14694        try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
14695    } else { 'Never' }
14696    "Enabled: $enabled"
14697    "BackupDrive: $target"
14698    "LastBackup: $lastBackup"
14699} else {
14700    "Enabled: Not configured"
14701    "BackupDrive: Not configured"
14702    "LastBackup: Never"
14703}
14704"#;
14705    match run_powershell(ps_fh) {
14706        Ok(o) if !o.trim().is_empty() => {
14707            for line in o.lines().take(6) {
14708                let l = line.trim();
14709                if !l.is_empty() {
14710                    out.push_str(&format!("- {l}\n"));
14711                }
14712            }
14713        }
14714        _ => out.push_str("- Could not inspect File History state\n"),
14715    }
14716
14717    out.push_str("\n=== Windows Backup (wbadmin) ===\n");
14718    let ps_wbadmin = r#"
14719$svc = Get-Service wbengine -ErrorAction SilentlyContinue
14720"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
14721# Last backup from wbadmin
14722$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
14723if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
14724    $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
14725    $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
14726    if ($lastDate) { $lastDate.Trim() }
14727    if ($lastTarget) { $lastTarget.Trim() }
14728} else {
14729    "LastWbadminBackup: No backup versions found"
14730}
14731# Task-based backup
14732$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
14733foreach ($t in $task) {
14734    "BackupTask: $($t.TaskName) | State: $($t.State)"
14735}
14736"#;
14737    match run_powershell(ps_wbadmin) {
14738        Ok(o) if !o.trim().is_empty() => {
14739            for line in o.lines().take(8) {
14740                let l = line.trim();
14741                if !l.is_empty() {
14742                    out.push_str(&format!("- {l}\n"));
14743                }
14744            }
14745        }
14746        _ => out.push_str("- Could not inspect Windows Backup state\n"),
14747    }
14748
14749    out.push_str("\n=== System Restore ===\n");
14750    let ps_sr = r#"
14751$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
14752    Select-Object -ExpandProperty DeviceID
14753foreach ($drive in $drives) {
14754    $protection = try {
14755        (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
14756    } catch { $null }
14757    $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
14758    $rpConf = try {
14759        Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
14760    } catch { $null }
14761    # Check if SR is disabled for this drive
14762    $disabled = $false
14763    $vssService = Get-Service VSS -ErrorAction SilentlyContinue
14764    "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
14765}
14766# Most recent restore point
14767$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
14768if ($points) {
14769    $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
14770    $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
14771    "MostRecentRestorePoint: $($latest.Description) | Created: $date"
14772} else {
14773    "MostRecentRestorePoint: None found"
14774}
14775$srEnabled = try {
14776    $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
14777    if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
14778} catch { 'Unknown' }
14779"SystemRestoreState: $srEnabled"
14780"#;
14781    match run_powershell(ps_sr) {
14782        Ok(o) if !o.trim().is_empty() => {
14783            for line in o.lines().take(8) {
14784                let l = line.trim();
14785                if !l.is_empty() {
14786                    out.push_str(&format!("- {l}\n"));
14787                }
14788            }
14789        }
14790        _ => out.push_str("- Could not inspect System Restore state\n"),
14791    }
14792
14793    out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
14794    let ps_kfm = r#"
14795$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
14796if (Test-Path $kfmKey) {
14797    $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
14798    foreach ($acct in $accounts | Select-Object -First 3) {
14799        $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
14800        $email = $props.UserEmail
14801        $kfmDesktop = $props.'KFMSilentOptInDesktop'
14802        $kfmDocs = $props.'KFMSilentOptInDocuments'
14803        $kfmPics = $props.'KFMSilentOptInPictures'
14804        "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' })"
14805    }
14806} else {
14807    "OneDriveKFM: No OneDrive accounts found"
14808}
14809"#;
14810    match run_powershell(ps_kfm) {
14811        Ok(o) if !o.trim().is_empty() => {
14812            for line in o.lines().take(6) {
14813                let l = line.trim();
14814                if !l.is_empty() {
14815                    out.push_str(&format!("- {l}\n"));
14816                }
14817            }
14818        }
14819        _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
14820    }
14821
14822    out.push_str("\n=== Recent backup failure events (7d) ===\n");
14823    let ps_events = r#"
14824$cutoff = (Get-Date).AddDays(-7)
14825$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14826    Where-Object {
14827        $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
14828        ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
14829    } |
14830    Where-Object { $_.Level -le 3 } |
14831    Select-Object -First 6
14832if ($events) {
14833    foreach ($event in $events) {
14834        $msg = ($event.Message -replace '\s+', ' ')
14835        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14836        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14837    }
14838} else {
14839    "No recent backup failure events detected"
14840}
14841"#;
14842    match run_powershell(ps_events) {
14843        Ok(o) if !o.trim().is_empty() => {
14844            for line in o.lines().take(8) {
14845                let l = line.trim();
14846                if !l.is_empty() {
14847                    out.push_str(&format!("- {l}\n"));
14848                }
14849            }
14850        }
14851        _ => out.push_str("- Could not inspect backup failure events\n"),
14852    }
14853
14854    let mut findings: Vec<String> = Vec::new();
14855
14856    let fh_enabled = out.contains("- Enabled: Enabled");
14857    let fh_never =
14858        out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
14859    let no_wbadmin = out.contains("No backup versions found");
14860    let no_restore_point = out.contains("MostRecentRestorePoint: None found");
14861
14862    if !fh_enabled && no_wbadmin {
14863        findings.push(
14864            "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(),
14865        );
14866    } else if fh_enabled && fh_never {
14867        findings.push(
14868            "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
14869        );
14870    }
14871
14872    if no_restore_point {
14873        findings.push(
14874            "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
14875        );
14876    }
14877
14878    if out.contains("- FileHistoryService: Stopped")
14879        || out.contains("- FileHistoryService: Not found")
14880    {
14881        findings.push(
14882            "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
14883        );
14884    }
14885
14886    if out.contains("Application Error |")
14887        || out.contains("Microsoft-Windows-Backup |")
14888        || out.contains("wbengine |")
14889    {
14890        findings.push(
14891            "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
14892        );
14893    }
14894
14895    let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
14896    if findings.is_empty() {
14897        result.push_str("- No obvious backup health blocker detected.\n");
14898    } else {
14899        for finding in &findings {
14900            result.push_str(&format!("- Finding: {finding}\n"));
14901        }
14902    }
14903    result.push('\n');
14904    result.push_str(&out);
14905    Ok(result)
14906}
14907
14908#[cfg(not(windows))]
14909fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
14910    Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
14911}
14912
14913#[cfg(windows)]
14914fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
14915    let mut out = String::from("=== Windows Search service ===\n");
14916
14917    // Service state
14918    let ps_svc = r#"
14919$svc = Get-Service WSearch -ErrorAction SilentlyContinue
14920if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
14921else { "WSearch service not found" }
14922"#;
14923    match run_powershell(ps_svc) {
14924        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
14925        Err(_) => out.push_str("- Could not query WSearch service\n"),
14926    }
14927
14928    // Indexer state via registry
14929    out.push_str("\n=== Indexer state ===\n");
14930    let ps_idx = r#"
14931$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
14932$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
14933if ($props) {
14934    "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
14935    "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
14936    "DataDirectory: $($props.DataDirectory)"
14937} else { "Registry key not found" }
14938"#;
14939    match run_powershell(ps_idx) {
14940        Ok(o) => {
14941            for line in o.lines() {
14942                let l = line.trim();
14943                if !l.is_empty() {
14944                    out.push_str(&format!("- {l}\n"));
14945                }
14946            }
14947        }
14948        Err(_) => out.push_str("- Could not read indexer registry\n"),
14949    }
14950
14951    // Indexed locations
14952    out.push_str("\n=== Indexed locations ===\n");
14953    let ps_locs = r#"
14954$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
14955if ($comObj) {
14956    $catalog = $comObj.GetCatalog('SystemIndex')
14957    $manager = $catalog.GetCrawlScopeManager()
14958    $rules = $manager.EnumerateRoots()
14959    while ($true) {
14960        try {
14961            $root = $rules.Next(1)
14962            if ($root.Count -eq 0) { break }
14963            $r = $root[0]
14964            "  $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
14965        } catch { break }
14966    }
14967} else { "  COM admin interface not available (normal on non-admin sessions)" }
14968"#;
14969    match run_powershell(ps_locs) {
14970        Ok(o) if !o.trim().is_empty() => {
14971            for line in o.lines() {
14972                let l = line.trim_end();
14973                if !l.is_empty() {
14974                    out.push_str(&format!("{l}\n"));
14975                }
14976            }
14977        }
14978        _ => {
14979            // Fallback: read from registry
14980            let ps_reg = r#"
14981Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
14982ForEach-Object { "  $($_.PSChildName)" } | Select-Object -First 20
14983"#;
14984            match run_powershell(ps_reg) {
14985                Ok(o) if !o.trim().is_empty() => {
14986                    for line in o.lines() {
14987                        let l = line.trim_end();
14988                        if !l.is_empty() {
14989                            out.push_str(&format!("{l}\n"));
14990                        }
14991                    }
14992                }
14993                _ => out.push_str("  - Could not enumerate indexed locations\n"),
14994            }
14995        }
14996    }
14997
14998    // Recent indexing errors from event log
14999    out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
15000    let ps_evts = r#"
15001Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
15002Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
15003ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
15004"#;
15005    match run_powershell(ps_evts) {
15006        Ok(o) if !o.trim().is_empty() => {
15007            for line in o.lines() {
15008                let l = line.trim();
15009                if !l.is_empty() {
15010                    out.push_str(&format!("- {l}\n"));
15011                }
15012            }
15013        }
15014        _ => out.push_str("- No recent indexer errors found\n"),
15015    }
15016
15017    let mut findings: Vec<String> = Vec::new();
15018    if out.contains("Status: Stopped") {
15019        findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
15020    }
15021    if out.contains("IsContentIndexingEnabled: 0")
15022        || out.contains("IsContentIndexingEnabled: False")
15023    {
15024        findings.push(
15025            "Content indexing is disabled — file content won't be searchable, only filenames."
15026                .into(),
15027        );
15028    }
15029    if out.contains("SetupCompletedSuccessfully: 0")
15030        || out.contains("SetupCompletedSuccessfully: False")
15031    {
15032        findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
15033    }
15034
15035    let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
15036    if findings.is_empty() {
15037        result.push_str("- Windows Search service and indexer appear healthy.\n");
15038        result.push_str("  If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
15039    } else {
15040        for f in &findings {
15041            result.push_str(&format!("- Finding: {f}\n"));
15042        }
15043    }
15044    result.push('\n');
15045    result.push_str(&out);
15046    Ok(result)
15047}
15048
15049#[cfg(not(windows))]
15050fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
15051    Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
15052}
15053
15054// ── inspect_display_config ────────────────────────────────────────────────────
15055
15056#[cfg(windows)]
15057fn inspect_display_config(max_entries: usize) -> Result<String, String> {
15058    let mut out = String::new();
15059
15060    // Active displays via CIM
15061    out.push_str("=== Active displays ===\n");
15062    let ps_displays = r#"
15063Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
15064Select-Object -First 20 |
15065ForEach-Object {
15066    "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
15067}
15068"#;
15069    match run_powershell(ps_displays) {
15070        Ok(o) if !o.trim().is_empty() => {
15071            for line in o.lines().take(max_entries) {
15072                let l = line.trim();
15073                if !l.is_empty() {
15074                    out.push_str(&format!("- {l}\n"));
15075                }
15076            }
15077        }
15078        _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
15079    }
15080
15081    // GPU / video adapter
15082    out.push_str("\n=== Video adapters ===\n");
15083    let ps_gpu = r#"
15084Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
15085ForEach-Object {
15086    $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
15087    $hz  = "$($_.CurrentRefreshRate) Hz"
15088    $bits = "$($_.CurrentBitsPerPixel) bpp"
15089    "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
15090}
15091"#;
15092    match run_powershell(ps_gpu) {
15093        Ok(o) if !o.trim().is_empty() => {
15094            for line in o.lines().take(max_entries) {
15095                let l = line.trim();
15096                if !l.is_empty() {
15097                    out.push_str(&format!("- {l}\n"));
15098                }
15099            }
15100        }
15101        _ => out.push_str("- Could not query video adapter info\n"),
15102    }
15103
15104    // Monitor names via Win32_DesktopMonitor
15105    out.push_str("\n=== Connected monitors ===\n");
15106    let ps_monitors = r#"
15107Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
15108ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
15109"#;
15110    match run_powershell(ps_monitors) {
15111        Ok(o) if !o.trim().is_empty() => {
15112            for line in o.lines().take(max_entries) {
15113                let l = line.trim();
15114                if !l.is_empty() {
15115                    out.push_str(&format!("- {l}\n"));
15116                }
15117            }
15118        }
15119        _ => out.push_str("- No monitor info available via WMI\n"),
15120    }
15121
15122    // DPI scaling
15123    out.push_str("\n=== DPI / scaling ===\n");
15124    let ps_dpi = r#"
15125Add-Type -TypeDefinition @'
15126using System; using System.Runtime.InteropServices;
15127public class DPI {
15128    [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
15129    [DllImport("gdi32")]  public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
15130    [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
15131}
15132'@ -ErrorAction SilentlyContinue
15133try {
15134    $hdc  = [DPI]::GetDC([IntPtr]::Zero)
15135    $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
15136    $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
15137    [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
15138    $scale = [Math]::Round($dpiX / 96.0 * 100)
15139    "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
15140} catch { "DPI query unavailable" }
15141"#;
15142    match run_powershell(ps_dpi) {
15143        Ok(o) if !o.trim().is_empty() => {
15144            out.push_str(&format!("- {}\n", o.trim()));
15145        }
15146        _ => out.push_str("- DPI info unavailable\n"),
15147    }
15148
15149    let mut findings: Vec<String> = Vec::new();
15150    if out.contains("0x0") || out.contains("@ 0 Hz") {
15151        findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
15152    }
15153
15154    let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
15155    if findings.is_empty() {
15156        result.push_str("- Display configuration appears normal.\n");
15157    } else {
15158        for f in &findings {
15159            result.push_str(&format!("- Finding: {f}\n"));
15160        }
15161    }
15162    result.push('\n');
15163    result.push_str(&out);
15164    Ok(result)
15165}
15166
15167#[cfg(not(windows))]
15168fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
15169    Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
15170}
15171
15172// ── inspect_ntp ───────────────────────────────────────────────────────────────
15173
15174#[cfg(windows)]
15175fn inspect_ntp() -> Result<String, String> {
15176    let mut out = String::new();
15177
15178    // w32tm status
15179    out.push_str("=== Windows Time service ===\n");
15180    let ps_svc = r#"
15181$svc = Get-Service W32Time -ErrorAction SilentlyContinue
15182if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
15183else { "W32Time service not found" }
15184"#;
15185    match run_powershell(ps_svc) {
15186        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
15187        Err(_) => out.push_str("- Could not query W32Time service\n"),
15188    }
15189
15190    // NTP source and last sync
15191    out.push_str("\n=== NTP source and sync status ===\n");
15192    let ps_sync = r#"
15193$q = w32tm /query /status 2>$null
15194if ($q) { $q } else { "w32tm query unavailable" }
15195"#;
15196    match run_powershell(ps_sync) {
15197        Ok(o) if !o.trim().is_empty() => {
15198            for line in o.lines() {
15199                let l = line.trim();
15200                if !l.is_empty() {
15201                    out.push_str(&format!("  {l}\n"));
15202                }
15203            }
15204        }
15205        _ => out.push_str("  - Could not query w32tm status\n"),
15206    }
15207
15208    // Configured NTP server
15209    out.push_str("\n=== Configured NTP servers ===\n");
15210    let ps_peers = r#"
15211w32tm /query /peers 2>$null | Select-Object -First 10
15212"#;
15213    match run_powershell(ps_peers) {
15214        Ok(o) if !o.trim().is_empty() => {
15215            for line in o.lines() {
15216                let l = line.trim();
15217                if !l.is_empty() {
15218                    out.push_str(&format!("  {l}\n"));
15219                }
15220            }
15221        }
15222        _ => {
15223            // Fallback: registry
15224            let ps_reg = r#"
15225(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15226"#;
15227            match run_powershell(ps_reg) {
15228                Ok(o) if !o.trim().is_empty() => {
15229                    out.push_str(&format!("  NtpServer (registry): {}\n", o.trim()));
15230                }
15231                _ => out.push_str("  - Could not enumerate NTP peers\n"),
15232            }
15233        }
15234    }
15235
15236    let mut findings: Vec<String> = Vec::new();
15237    if out.contains("W32Time | Status: Stopped") {
15238        findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15239    }
15240    if out.contains("The computer did not resync") || out.contains("Error") {
15241        findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15242    }
15243
15244    let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15245    if findings.is_empty() {
15246        result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15247    } else {
15248        for f in &findings {
15249            result.push_str(&format!("- Finding: {f}\n"));
15250        }
15251    }
15252    result.push('\n');
15253    result.push_str(&out);
15254    Ok(result)
15255}
15256
15257#[cfg(not(windows))]
15258fn inspect_ntp() -> Result<String, String> {
15259    // Linux/macOS: check timedatectl / chrony / ntpq
15260    let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15261
15262    let timedatectl = std::process::Command::new("timedatectl")
15263        .arg("status")
15264        .output();
15265
15266    if let Ok(o) = timedatectl {
15267        let text = String::from_utf8_lossy(&o.stdout);
15268        if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
15269            out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
15270        } else {
15271            out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
15272        }
15273        for line in text.lines() {
15274            let l = line.trim();
15275            if !l.is_empty() {
15276                out.push_str(&format!("  {l}\n"));
15277            }
15278        }
15279        return Ok(out);
15280    }
15281
15282    // macOS fallback
15283    let sntp = std::process::Command::new("sntp")
15284        .args(["-d", "time.apple.com"])
15285        .output();
15286    if let Ok(o) = sntp {
15287        out.push_str("- NTP check via sntp:\n");
15288        out.push_str(&String::from_utf8_lossy(&o.stdout));
15289        return Ok(out);
15290    }
15291
15292    out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
15293    Ok(out)
15294}
15295
15296// ── inspect_cpu_power ─────────────────────────────────────────────────────────
15297
15298#[cfg(windows)]
15299fn inspect_cpu_power() -> Result<String, String> {
15300    let mut out = String::new();
15301
15302    // Active power plan
15303    out.push_str("=== Active power plan ===\n");
15304    let ps_plan = r#"
15305$plan = powercfg /getactivescheme 2>$null
15306if ($plan) { $plan } else { "Could not query power scheme" }
15307"#;
15308    match run_powershell(ps_plan) {
15309        Ok(o) if !o.trim().is_empty() => out.push_str(&format!("- {}\n", o.trim())),
15310        _ => out.push_str("- Could not read active power plan\n"),
15311    }
15312
15313    // Processor min/max state and boost policy
15314    out.push_str("\n=== Processor performance policy ===\n");
15315    let ps_proc = r#"
15316$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
15317$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15318$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15319$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15320if ($min)   { "Min processor state:  $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
15321if ($max)   { "Max processor state:  $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
15322if ($boost) {
15323    $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
15324    $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
15325    "Turbo boost mode:     $bname"
15326}
15327"#;
15328    match run_powershell(ps_proc) {
15329        Ok(o) if !o.trim().is_empty() => {
15330            for line in o.lines() {
15331                let l = line.trim();
15332                if !l.is_empty() {
15333                    out.push_str(&format!("- {l}\n"));
15334                }
15335            }
15336        }
15337        _ => out.push_str("- Could not query processor performance settings\n"),
15338    }
15339
15340    // Current CPU frequency via WMI
15341    out.push_str("\n=== CPU frequency ===\n");
15342    let ps_freq = r#"
15343Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
15344ForEach-Object {
15345    $cur = $_.CurrentClockSpeed
15346    $max = $_.MaxClockSpeed
15347    $load = $_.LoadPercentage
15348    "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
15349}
15350"#;
15351    match run_powershell(ps_freq) {
15352        Ok(o) if !o.trim().is_empty() => {
15353            for line in o.lines() {
15354                let l = line.trim();
15355                if !l.is_empty() {
15356                    out.push_str(&format!("- {l}\n"));
15357                }
15358            }
15359        }
15360        _ => out.push_str("- Could not query CPU frequency via WMI\n"),
15361    }
15362
15363    // Throttle reason from ETW (quick check)
15364    out.push_str("\n=== Throttling indicators ===\n");
15365    let ps_throttle = r#"
15366$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
15367if ($pwr) {
15368    $pwr | Select-Object -First 4 | ForEach-Object {
15369        $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
15370        "Thermal zone $($_.InstanceName): ${c}°C"
15371    }
15372} else { "Thermal zone WMI not available (normal on consumer hardware)" }
15373"#;
15374    match run_powershell(ps_throttle) {
15375        Ok(o) if !o.trim().is_empty() => {
15376            for line in o.lines() {
15377                let l = line.trim();
15378                if !l.is_empty() {
15379                    out.push_str(&format!("- {l}\n"));
15380                }
15381            }
15382        }
15383        _ => out.push_str("- Thermal zone info unavailable\n"),
15384    }
15385
15386    let mut findings: Vec<String> = Vec::new();
15387    if out.contains("Max processor state:  0%") || out.contains("Max processor state:  1%") {
15388        findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
15389    }
15390    if out.contains("Turbo boost mode:     Disabled") {
15391        findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
15392    }
15393    if out.contains("Min processor state:  100%") {
15394        findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
15395    }
15396
15397    let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
15398    if findings.is_empty() {
15399        result.push_str("- CPU power and frequency settings appear normal.\n");
15400    } else {
15401        for f in &findings {
15402            result.push_str(&format!("- Finding: {f}\n"));
15403        }
15404    }
15405    result.push('\n');
15406    result.push_str(&out);
15407    Ok(result)
15408}
15409
15410#[cfg(windows)]
15411fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15412    let mut out = String::new();
15413
15414    out.push_str("=== Credential vault summary ===\n");
15415    let ps_summary = r#"
15416$raw = cmdkey /list 2>&1
15417$lines = $raw -split "`n"
15418$total = ($lines | Where-Object { $_ -match "Target:" }).Count
15419"Total stored credentials: $total"
15420$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
15421$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
15422$cert    = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
15423"  Windows credentials: $windows"
15424"  Generic credentials: $generic"
15425"  Certificate-based:   $cert"
15426"#;
15427    match run_powershell(ps_summary) {
15428        Ok(o) => {
15429            for line in o.lines() {
15430                let l = line.trim();
15431                if !l.is_empty() {
15432                    out.push_str(&format!("- {l}\n"));
15433                }
15434            }
15435        }
15436        Err(e) => out.push_str(&format!("- Credential summary error: {e}\n")),
15437    }
15438
15439    out.push_str("\n=== Credential targets (up to 20) ===\n");
15440    let ps_list = r#"
15441$raw = cmdkey /list 2>&1
15442$entries = @(); $cur = @{}
15443foreach ($line in ($raw -split "`n")) {
15444    $l = $line.Trim()
15445    if     ($l -match "^Target:\s*(.+)")  { $cur = @{ Target=$Matches[1] } }
15446    elseif ($l -match "^Type:\s*(.+)"   -and $cur.Target) { $cur.Type=$Matches[1] }
15447    elseif ($l -match "^User:\s*(.+)"   -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
15448}
15449$entries | Select-Object -Last 20 | ForEach-Object {
15450    "[$($_.Type)] $($_.Target)  (user: $($_.User))"
15451}
15452"#;
15453    match run_powershell(ps_list) {
15454        Ok(o) => {
15455            let lines: Vec<&str> = o
15456                .lines()
15457                .map(|l| l.trim())
15458                .filter(|l| !l.is_empty())
15459                .collect();
15460            if lines.is_empty() {
15461                out.push_str("- No credential entries found\n");
15462            } else {
15463                for l in &lines {
15464                    out.push_str(&format!("- {l}\n"));
15465                }
15466            }
15467        }
15468        Err(e) => out.push_str(&format!("- Credential list error: {e}\n")),
15469    }
15470
15471    let total_creds: usize = {
15472        let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
15473        run_powershell(ps_count)
15474            .ok()
15475            .and_then(|s| s.trim().parse().ok())
15476            .unwrap_or(0)
15477    };
15478
15479    let mut findings: Vec<String> = Vec::new();
15480    if total_creds > 30 {
15481        findings.push(format!(
15482            "{total_creds} stored credentials found — consider auditing for stale entries."
15483        ));
15484    }
15485
15486    let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
15487    if findings.is_empty() {
15488        result.push_str("- Credential store looks normal.\n");
15489    } else {
15490        for f in &findings {
15491            result.push_str(&format!("- Finding: {f}\n"));
15492        }
15493    }
15494    result.push('\n');
15495    result.push_str(&out);
15496    Ok(result)
15497}
15498
15499#[cfg(not(windows))]
15500fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15501    Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
15502}
15503
15504#[cfg(windows)]
15505fn inspect_tpm() -> Result<String, String> {
15506    let mut out = String::new();
15507
15508    out.push_str("=== TPM state ===\n");
15509    let ps_tpm = r#"
15510function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
15511    $text = if ($null -eq $Value) { "" } else { [string]$Value }
15512    if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
15513    "$Name$text"
15514}
15515$t = Get-Tpm -ErrorAction SilentlyContinue
15516if ($t) {
15517    Emit-Field "TpmPresent:          " $t.TpmPresent
15518    Emit-Field "TpmReady:            " $t.TpmReady
15519    Emit-Field "TpmEnabled:          " $t.TpmEnabled
15520    Emit-Field "TpmOwned:            " $t.TpmOwned
15521    Emit-Field "RestartPending:      " $t.RestartPending
15522    Emit-Field "ManufacturerIdTxt:   " $t.ManufacturerIdTxt
15523    Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
15524} else { "TPM module unavailable" }
15525"#;
15526    match run_powershell(ps_tpm) {
15527        Ok(o) => {
15528            for line in o.lines() {
15529                let l = line.trim();
15530                if !l.is_empty() {
15531                    out.push_str(&format!("- {l}\n"));
15532                }
15533            }
15534        }
15535        Err(e) => out.push_str(&format!("- Get-Tpm error: {e}\n")),
15536    }
15537
15538    out.push_str("\n=== TPM spec version (WMI) ===\n");
15539    let ps_spec = r#"
15540$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
15541if ($wmi) {
15542    $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
15543    "SpecVersion:  $spec"
15544    "IsActivated:  $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
15545    "IsEnabled:    $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
15546    "IsOwned:      $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
15547} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
15548"#;
15549    match run_powershell(ps_spec) {
15550        Ok(o) => {
15551            for line in o.lines() {
15552                let l = line.trim();
15553                if !l.is_empty() {
15554                    out.push_str(&format!("- {l}\n"));
15555                }
15556            }
15557        }
15558        Err(e) => out.push_str(&format!("- Win32_Tpm WMI error: {e}\n")),
15559    }
15560
15561    out.push_str("\n=== Secure Boot state ===\n");
15562    let ps_sb = r#"
15563try {
15564    $sb = Confirm-SecureBootUEFI -ErrorAction Stop
15565    if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
15566} catch {
15567    $msg = $_.Exception.Message
15568    if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
15569        "Secure Boot: Unknown (administrator privileges required)"
15570    } elseif ($msg -match "Cmdlet not supported on this platform") {
15571        "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
15572    } else {
15573        "Secure Boot: N/A ($msg)"
15574    }
15575}
15576"#;
15577    match run_powershell(ps_sb) {
15578        Ok(o) => {
15579            for line in o.lines() {
15580                let l = line.trim();
15581                if !l.is_empty() {
15582                    out.push_str(&format!("- {l}\n"));
15583                }
15584            }
15585        }
15586        Err(e) => out.push_str(&format!("- Secure Boot check error: {e}\n")),
15587    }
15588
15589    out.push_str("\n=== Firmware type ===\n");
15590    let ps_fw = r#"
15591$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
15592switch ($fw) {
15593    1 { "Firmware type: BIOS (Legacy)" }
15594    2 { "Firmware type: UEFI" }
15595    default {
15596        $bcd = bcdedit /enum firmware 2>$null
15597        if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
15598        else { "Firmware type: Unknown or not set" }
15599    }
15600}
15601"#;
15602    match run_powershell(ps_fw) {
15603        Ok(o) => {
15604            for line in o.lines() {
15605                let l = line.trim();
15606                if !l.is_empty() {
15607                    out.push_str(&format!("- {l}\n"));
15608                }
15609            }
15610        }
15611        Err(e) => out.push_str(&format!("- Firmware type error: {e}\n")),
15612    }
15613
15614    let mut findings: Vec<String> = Vec::new();
15615    let mut indeterminate = false;
15616    if out.contains("TpmPresent:          False") {
15617        findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
15618    }
15619    if out.contains("TpmReady:            False") {
15620        findings.push(
15621            "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
15622        );
15623    }
15624    if out.contains("SpecVersion:  1.2") {
15625        findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
15626    }
15627    if out.contains("Secure Boot: DISABLED") {
15628        findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
15629    }
15630    if out.contains("Firmware type: BIOS (Legacy)") {
15631        findings.push(
15632            "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
15633        );
15634    }
15635
15636    if out.contains("TPM module unavailable")
15637        || out.contains("Win32_Tpm WMI class unavailable")
15638        || out.contains("Secure Boot: N/A")
15639        || out.contains("Secure Boot: Unknown")
15640        || out.contains("Firmware type: Unknown or not set")
15641        || out.contains("TpmPresent:          Unknown")
15642        || out.contains("TpmReady:            Unknown")
15643        || out.contains("TpmEnabled:          Unknown")
15644    {
15645        indeterminate = true;
15646    }
15647    if indeterminate {
15648        findings.push(
15649            "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
15650                .into(),
15651        );
15652    }
15653
15654    let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
15655    if findings.is_empty() {
15656        result.push_str("- TPM and Secure Boot appear healthy.\n");
15657    } else {
15658        for f in &findings {
15659            result.push_str(&format!("- Finding: {f}\n"));
15660        }
15661    }
15662    result.push('\n');
15663    result.push_str(&out);
15664    Ok(result)
15665}
15666
15667#[cfg(not(windows))]
15668fn inspect_tpm() -> Result<String, String> {
15669    Ok(
15670        "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
15671            .into(),
15672    )
15673}
15674
15675#[cfg(windows)]
15676fn inspect_latency() -> Result<String, String> {
15677    let mut out = String::new();
15678
15679    // Resolve default gateway from the routing table
15680    let ps_gw = r#"
15681$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
15682       Sort-Object RouteMetric | Select-Object -First 1).NextHop
15683if ($gw) { $gw } else { "" }
15684"#;
15685    let gateway = run_powershell(ps_gw)
15686        .ok()
15687        .map(|s| s.trim().to_string())
15688        .filter(|s| !s.is_empty());
15689
15690    let targets: Vec<(&str, String)> = {
15691        let mut t = Vec::new();
15692        if let Some(ref gw) = gateway {
15693            t.push(("Default gateway", gw.clone()));
15694        }
15695        t.push(("Cloudflare DNS", "1.1.1.1".into()));
15696        t.push(("Google DNS", "8.8.8.8".into()));
15697        t
15698    };
15699
15700    let mut findings: Vec<String> = Vec::new();
15701
15702    for (label, host) in &targets {
15703        out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
15704        // Test-NetConnection gives RTT; -InformationLevel Quiet just returns bool, so use ping
15705        let ps_ping = format!(
15706            r#"
15707$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
15708if ($r) {{
15709    $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
15710    $min  = ($rtts | Measure-Object -Minimum).Minimum
15711    $max  = ($rtts | Measure-Object -Maximum).Maximum
15712    $avg  = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
15713    $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
15714    "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
15715    "Packet loss: ${{loss}}%"
15716    "Sent: 4  Received: $($r.Count)"
15717}} else {{
15718    "UNREACHABLE — 100% packet loss"
15719}}
15720"#
15721        );
15722        match run_powershell(&ps_ping) {
15723            Ok(o) => {
15724                let body = o.trim().to_string();
15725                for line in body.lines() {
15726                    let l = line.trim();
15727                    if !l.is_empty() {
15728                        out.push_str(&format!("- {l}\n"));
15729                    }
15730                }
15731                if body.contains("UNREACHABLE") {
15732                    findings.push(format!(
15733                        "{label} ({host}) is unreachable — possible routing or firewall issue."
15734                    ));
15735                } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
15736                    let pct: u32 = loss_line
15737                        .chars()
15738                        .filter(|c| c.is_ascii_digit())
15739                        .collect::<String>()
15740                        .parse()
15741                        .unwrap_or(0);
15742                    if pct >= 25 {
15743                        findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
15744                    }
15745                    // High latency check
15746                    if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
15747                        // parse avg from "RTT min/avg/max: Xms / Yms / Zms"
15748                        let parts: Vec<&str> = rtt_line.split('/').collect();
15749                        if parts.len() >= 2 {
15750                            let avg_str: String =
15751                                parts[1].chars().filter(|c| c.is_ascii_digit()).collect();
15752                            let avg: u32 = avg_str.parse().unwrap_or(0);
15753                            if avg > 150 {
15754                                findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
15755                            }
15756                        }
15757                    }
15758                }
15759            }
15760            Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
15761        }
15762    }
15763
15764    let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
15765    if findings.is_empty() {
15766        result.push_str("- Latency and reachability look normal.\n");
15767    } else {
15768        for f in &findings {
15769            result.push_str(&format!("- Finding: {f}\n"));
15770        }
15771    }
15772    result.push('\n');
15773    result.push_str(&out);
15774    Ok(result)
15775}
15776
15777#[cfg(not(windows))]
15778fn inspect_latency() -> Result<String, String> {
15779    let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
15780    let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
15781    let mut findings: Vec<String> = Vec::new();
15782
15783    for (label, host) in &targets {
15784        out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
15785        let ping = std::process::Command::new("ping")
15786            .args(["-c", "4", "-W", "2", host])
15787            .output();
15788        match ping {
15789            Ok(o) => {
15790                let body = String::from_utf8_lossy(&o.stdout).into_owned();
15791                for line in body.lines() {
15792                    let l = line.trim();
15793                    if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
15794                        out.push_str(&format!("- {l}\n"));
15795                    }
15796                }
15797                if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
15798                    findings.push(format!("{label} ({host}) is unreachable."));
15799                }
15800            }
15801            Err(e) => out.push_str(&format!("- ping error: {e}\n")),
15802        }
15803    }
15804
15805    if findings.is_empty() {
15806        out.insert_str(
15807            "Host inspection: latency\n\n=== Findings ===\n".len(),
15808            "- Latency and reachability look normal.\n",
15809        );
15810    } else {
15811        let mut prefix = String::new();
15812        for f in &findings {
15813            prefix.push_str(&format!("- Finding: {f}\n"));
15814        }
15815        out.insert_str(
15816            "Host inspection: latency\n\n=== Findings ===\n".len(),
15817            &prefix,
15818        );
15819    }
15820    Ok(out)
15821}
15822
15823#[cfg(windows)]
15824fn inspect_network_adapter() -> Result<String, String> {
15825    let mut out = String::new();
15826
15827    out.push_str("=== Network adapters ===\n");
15828    let ps_adapters = r#"
15829Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
15830    $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
15831    "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
15832}
15833"#;
15834    match run_powershell(ps_adapters) {
15835        Ok(o) => {
15836            for line in o.lines() {
15837                let l = line.trim();
15838                if !l.is_empty() {
15839                    out.push_str(&format!("- {l}\n"));
15840                }
15841            }
15842        }
15843        Err(e) => out.push_str(&format!("- Adapter query error: {e}\n")),
15844    }
15845
15846    out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
15847    let ps_offload = r#"
15848Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
15849    $name = $_.Name
15850    $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
15851        Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
15852        Select-Object DisplayName, DisplayValue
15853    if ($props) {
15854        "--- $name ---"
15855        $props | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
15856    }
15857}
15858"#;
15859    match run_powershell(ps_offload) {
15860        Ok(o) => {
15861            let lines: Vec<&str> = o
15862                .lines()
15863                .map(|l| l.trim())
15864                .filter(|l| !l.is_empty())
15865                .collect();
15866            if lines.is_empty() {
15867                out.push_str(
15868                    "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
15869                );
15870            } else {
15871                for l in &lines {
15872                    out.push_str(&format!("- {l}\n"));
15873                }
15874            }
15875        }
15876        Err(e) => out.push_str(&format!("- Offload query error: {e}\n")),
15877    }
15878
15879    out.push_str("\n=== Adapter error counters ===\n");
15880    let ps_errors = r#"
15881Get-NetAdapterStatistics | ForEach-Object {
15882    $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
15883    if ($errs -gt 0) {
15884        "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
15885    }
15886}
15887"#;
15888    match run_powershell(ps_errors) {
15889        Ok(o) => {
15890            let lines: Vec<&str> = o
15891                .lines()
15892                .map(|l| l.trim())
15893                .filter(|l| !l.is_empty())
15894                .collect();
15895            if lines.is_empty() {
15896                out.push_str("- No adapter errors or discards detected.\n");
15897            } else {
15898                for l in &lines {
15899                    out.push_str(&format!("- {l}\n"));
15900                }
15901            }
15902        }
15903        Err(e) => out.push_str(&format!("- Error counter query: {e}\n")),
15904    }
15905
15906    out.push_str("\n=== Wake-on-LAN and power settings ===\n");
15907    let ps_wol = r#"
15908Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
15909    $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
15910    if ($wol) {
15911        "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
15912    }
15913}
15914"#;
15915    match run_powershell(ps_wol) {
15916        Ok(o) => {
15917            let lines: Vec<&str> = o
15918                .lines()
15919                .map(|l| l.trim())
15920                .filter(|l| !l.is_empty())
15921                .collect();
15922            if lines.is_empty() {
15923                out.push_str("- Power management data unavailable for active adapters.\n");
15924            } else {
15925                for l in &lines {
15926                    out.push_str(&format!("- {l}\n"));
15927                }
15928            }
15929        }
15930        Err(e) => out.push_str(&format!("- WoL query error: {e}\n")),
15931    }
15932
15933    let mut findings: Vec<String> = Vec::new();
15934    // Check for error-prone adapters
15935    if out.contains("RX errors:") || out.contains("TX errors:") {
15936        findings
15937            .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
15938    }
15939    // Check for half-duplex (rare but still seen on older switches)
15940    if out.contains("Half") {
15941        findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
15942    }
15943
15944    let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
15945    if findings.is_empty() {
15946        result.push_str("- Network adapter configuration looks normal.\n");
15947    } else {
15948        for f in &findings {
15949            result.push_str(&format!("- Finding: {f}\n"));
15950        }
15951    }
15952    result.push('\n');
15953    result.push_str(&out);
15954    Ok(result)
15955}
15956
15957#[cfg(not(windows))]
15958fn inspect_network_adapter() -> Result<String, String> {
15959    let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
15960
15961    out.push_str("=== Network adapters (ip link) ===\n");
15962    let ip_link = std::process::Command::new("ip")
15963        .args(["link", "show"])
15964        .output();
15965    if let Ok(o) = ip_link {
15966        for line in String::from_utf8_lossy(&o.stdout).lines() {
15967            let l = line.trim();
15968            if !l.is_empty() {
15969                out.push_str(&format!("- {l}\n"));
15970            }
15971        }
15972    }
15973
15974    out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
15975    let ip_stats = std::process::Command::new("ip")
15976        .args(["-s", "link", "show"])
15977        .output();
15978    if let Ok(o) = ip_stats {
15979        for line in String::from_utf8_lossy(&o.stdout).lines() {
15980            let l = line.trim();
15981            if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
15982            {
15983                out.push_str(&format!("- {l}\n"));
15984            }
15985        }
15986    }
15987    Ok(out)
15988}
15989
15990#[cfg(windows)]
15991fn inspect_dhcp() -> Result<String, String> {
15992    let mut out = String::new();
15993
15994    out.push_str("=== DHCP lease details (per adapter) ===\n");
15995    let ps_dhcp = r#"
15996$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
15997    Where-Object { $_.IPEnabled -eq $true }
15998foreach ($a in $adapters) {
15999    "--- $($a.Description) ---"
16000    "  DHCP Enabled:      $($a.DHCPEnabled)"
16001    if ($a.DHCPEnabled) {
16002        "  DHCP Server:       $($a.DHCPServer)"
16003        $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
16004        $expires  = $a.ConvertToDateTime($a.DHCPLeaseExpires)  2>$null
16005        "  Lease Obtained:    $obtained"
16006        "  Lease Expires:     $expires"
16007    }
16008    "  IP Address:        $($a.IPAddress -join ', ')"
16009    "  Subnet Mask:       $($a.IPSubnet -join ', ')"
16010    "  Default Gateway:   $($a.DefaultIPGateway -join ', ')"
16011    "  DNS Servers:       $($a.DNSServerSearchOrder -join ', ')"
16012    "  MAC Address:       $($a.MACAddress)"
16013    ""
16014}
16015"#;
16016    match run_powershell(ps_dhcp) {
16017        Ok(o) => {
16018            for line in o.lines() {
16019                let l = line.trim_end();
16020                if !l.is_empty() {
16021                    out.push_str(&format!("{l}\n"));
16022                }
16023            }
16024        }
16025        Err(e) => out.push_str(&format!("- DHCP query error: {e}\n")),
16026    }
16027
16028    // Findings: check for expired or very-soon-expiring leases
16029    let mut findings: Vec<String> = Vec::new();
16030    let ps_expiry = r#"
16031$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
16032foreach ($a in $adapters) {
16033    try {
16034        $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
16035        $now = Get-Date
16036        $hrs = ($exp - $now).TotalHours
16037        if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
16038        elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
16039    } catch {}
16040}
16041"#;
16042    if let Ok(o) = run_powershell(ps_expiry) {
16043        for line in o.lines() {
16044            let l = line.trim();
16045            if !l.is_empty() {
16046                if l.contains("EXPIRED") {
16047                    findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
16048                } else if l.contains("expires in") {
16049                    findings.push(format!("DHCP lease expiring soon — {l}"));
16050                }
16051            }
16052        }
16053    }
16054
16055    let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
16056    if findings.is_empty() {
16057        result.push_str("- DHCP leases look healthy.\n");
16058    } else {
16059        for f in &findings {
16060            result.push_str(&format!("- Finding: {f}\n"));
16061        }
16062    }
16063    result.push('\n');
16064    result.push_str(&out);
16065    Ok(result)
16066}
16067
16068#[cfg(not(windows))]
16069fn inspect_dhcp() -> Result<String, String> {
16070    let mut out = String::from(
16071        "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
16072    );
16073    out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
16074    for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
16075        if std::path::Path::new(path).exists() {
16076            let cat = std::process::Command::new("cat").arg(path).output();
16077            if let Ok(o) = cat {
16078                let text = String::from_utf8_lossy(&o.stdout);
16079                for line in text.lines().take(40) {
16080                    let l = line.trim();
16081                    if l.contains("lease")
16082                        || l.contains("expire")
16083                        || l.contains("server")
16084                        || l.contains("address")
16085                    {
16086                        out.push_str(&format!("- {l}\n"));
16087                    }
16088                }
16089            }
16090        }
16091    }
16092    // Also try ip addr for current IPs
16093    let ip = std::process::Command::new("ip")
16094        .args(["addr", "show"])
16095        .output();
16096    if let Ok(o) = ip {
16097        out.push_str("\n=== Current IP addresses (ip addr) ===\n");
16098        for line in String::from_utf8_lossy(&o.stdout).lines() {
16099            let l = line.trim();
16100            if l.starts_with("inet") || l.contains("dynamic") {
16101                out.push_str(&format!("- {l}\n"));
16102            }
16103        }
16104    }
16105    Ok(out)
16106}
16107
16108#[cfg(windows)]
16109fn inspect_mtu() -> Result<String, String> {
16110    let mut out = String::new();
16111
16112    out.push_str("=== Per-adapter MTU (IPv4) ===\n");
16113    let ps_mtu = r#"
16114Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
16115    Sort-Object ConnectionState, InterfaceAlias |
16116    ForEach-Object {
16117        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16118    }
16119"#;
16120    match run_powershell(ps_mtu) {
16121        Ok(o) => {
16122            for line in o.lines() {
16123                let l = line.trim();
16124                if !l.is_empty() {
16125                    out.push_str(&format!("- {l}\n"));
16126                }
16127            }
16128        }
16129        Err(e) => out.push_str(&format!("- MTU query error: {e}\n")),
16130    }
16131
16132    out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
16133    let ps_mtu6 = r#"
16134Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
16135    Sort-Object ConnectionState, InterfaceAlias |
16136    ForEach-Object {
16137        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
16138    }
16139"#;
16140    match run_powershell(ps_mtu6) {
16141        Ok(o) => {
16142            for line in o.lines() {
16143                let l = line.trim();
16144                if !l.is_empty() {
16145                    out.push_str(&format!("- {l}\n"));
16146                }
16147            }
16148        }
16149        Err(e) => out.push_str(&format!("- IPv6 MTU query error: {e}\n")),
16150    }
16151
16152    out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
16153    // Send a 1472-byte payload (1500 - 28 IP+ICMP headers) to test standard Ethernet MTU
16154    let ps_pmtu = r#"
16155$sizes = @(1472, 1400, 1280, 576)
16156$result = $null
16157foreach ($s in $sizes) {
16158    $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
16159    if ($r) { $result = $s; break }
16160}
16161if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
16162else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
16163"#;
16164    match run_powershell(ps_pmtu) {
16165        Ok(o) => {
16166            for line in o.lines() {
16167                let l = line.trim();
16168                if !l.is_empty() {
16169                    out.push_str(&format!("- {l}\n"));
16170                }
16171            }
16172        }
16173        Err(e) => out.push_str(&format!("- Path MTU test error: {e}\n")),
16174    }
16175
16176    let mut findings: Vec<String> = Vec::new();
16177    if out.contains("MTU: 576 bytes") {
16178        findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
16179    }
16180    if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
16181        findings.push(
16182            "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
16183                .into(),
16184        );
16185    }
16186    if out.contains("All test sizes failed") {
16187        findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
16188    }
16189
16190    let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16191    if findings.is_empty() {
16192        result.push_str("- MTU configuration looks normal.\n");
16193    } else {
16194        for f in &findings {
16195            result.push_str(&format!("- Finding: {f}\n"));
16196        }
16197    }
16198    result.push('\n');
16199    result.push_str(&out);
16200    Ok(result)
16201}
16202
16203#[cfg(not(windows))]
16204fn inspect_mtu() -> Result<String, String> {
16205    let mut out = String::from(
16206        "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
16207    );
16208
16209    out.push_str("=== Per-interface MTU (ip link) ===\n");
16210    let ip = std::process::Command::new("ip")
16211        .args(["link", "show"])
16212        .output();
16213    if let Ok(o) = ip {
16214        for line in String::from_utf8_lossy(&o.stdout).lines() {
16215            let l = line.trim();
16216            if l.contains("mtu") || l.starts_with("\\d") {
16217                out.push_str(&format!("- {l}\n"));
16218            }
16219        }
16220    }
16221
16222    out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
16223    let ping = std::process::Command::new("ping")
16224        .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
16225        .output();
16226    match ping {
16227        Ok(o) => {
16228            let body = String::from_utf8_lossy(&o.stdout);
16229            for line in body.lines() {
16230                let l = line.trim();
16231                if !l.is_empty() {
16232                    out.push_str(&format!("- {l}\n"));
16233                }
16234            }
16235        }
16236        Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16237    }
16238    Ok(out)
16239}
16240
16241#[cfg(not(windows))]
16242fn inspect_cpu_power() -> Result<String, String> {
16243    let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
16244
16245    // Linux: cpufreq-info or /sys/devices/system/cpu
16246    out.push_str("=== CPU frequency (Linux) ===\n");
16247    let cat_scaling = std::process::Command::new("cat")
16248        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
16249        .output();
16250    if let Ok(o) = cat_scaling {
16251        let khz: u64 = String::from_utf8_lossy(&o.stdout)
16252            .trim()
16253            .parse()
16254            .unwrap_or(0);
16255        if khz > 0 {
16256            out.push_str(&format!("- Current: {} MHz\n", khz / 1000));
16257        }
16258    }
16259    let cat_max = std::process::Command::new("cat")
16260        .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
16261        .output();
16262    if let Ok(o) = cat_max {
16263        let khz: u64 = String::from_utf8_lossy(&o.stdout)
16264            .trim()
16265            .parse()
16266            .unwrap_or(0);
16267        if khz > 0 {
16268            out.push_str(&format!("- Max: {} MHz\n", khz / 1000));
16269        }
16270    }
16271    let governor = std::process::Command::new("cat")
16272        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
16273        .output();
16274    if let Ok(o) = governor {
16275        let g = String::from_utf8_lossy(&o.stdout);
16276        let g = g.trim();
16277        if !g.is_empty() {
16278            out.push_str(&format!("- Governor: {g}\n"));
16279        }
16280    }
16281    Ok(out)
16282}
16283
16284// ── IPv6 ────────────────────────────────────────────────────────────────────
16285
16286#[cfg(windows)]
16287fn inspect_ipv6() -> Result<String, String> {
16288    let script = r#"
16289$result = [System.Text.StringBuilder]::new()
16290
16291# Per-adapter IPv6 addresses
16292$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
16293$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16294    Where-Object { $_.IPAddress -notmatch '^::1$' } |
16295    Sort-Object InterfaceAlias
16296foreach ($a in $adapters) {
16297    $prefix = $a.PrefixOrigin
16298    $suffix = $a.SuffixOrigin
16299    $scope  = $a.AddressState
16300    $result.AppendLine("  [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength)  origin=$prefix/$suffix  state=$scope") | Out-Null
16301}
16302if (-not $adapters) { $result.AppendLine("  No global/link-local IPv6 addresses found.") | Out-Null }
16303
16304# Default gateway IPv6
16305$result.AppendLine("") | Out-Null
16306$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
16307$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
16308if ($gw6) {
16309    foreach ($g in $gw6) {
16310        $result.AppendLine("  [$($g.InterfaceAlias)] via $($g.NextHop)  metric=$($g.RouteMetric)") | Out-Null
16311    }
16312} else {
16313    $result.AppendLine("  No IPv6 default gateway configured.") | Out-Null
16314}
16315
16316# DHCPv6 lease info
16317$result.AppendLine("") | Out-Null
16318$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
16319$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16320    Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
16321if ($dhcpv6) {
16322    foreach ($d in $dhcpv6) {
16323        $result.AppendLine("  [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
16324    }
16325} else {
16326    $result.AppendLine("  No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
16327}
16328
16329# Privacy extensions
16330$result.AppendLine("") | Out-Null
16331$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
16332try {
16333    $priv = netsh interface ipv6 show privacy
16334    $result.AppendLine(($priv -join "`n")) | Out-Null
16335} catch {
16336    $result.AppendLine("  Could not retrieve privacy extension state.") | Out-Null
16337}
16338
16339# Tunnel adapters
16340$result.AppendLine("") | Out-Null
16341$result.AppendLine("=== Tunnel adapters ===") | Out-Null
16342$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
16343if ($tunnels) {
16344    foreach ($t in $tunnels) {
16345        $result.AppendLine("  $($t.Name): $($t.InterfaceDescription)  Status=$($t.Status)") | Out-Null
16346    }
16347} else {
16348    $result.AppendLine("  No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
16349}
16350
16351# Findings
16352$findings = [System.Collections.Generic.List[string]]::new()
16353$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16354    Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
16355if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
16356$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
16357if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
16358
16359$result.AppendLine("") | Out-Null
16360$result.AppendLine("=== Findings ===") | Out-Null
16361if ($findings.Count -eq 0) {
16362    $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
16363} else {
16364    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16365}
16366
16367Write-Output $result.ToString()
16368"#;
16369    let out = run_powershell(script)?;
16370    Ok(format!("Host inspection: ipv6\n\n{out}"))
16371}
16372
16373#[cfg(not(windows))]
16374fn inspect_ipv6() -> Result<String, String> {
16375    let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
16376    if let Ok(o) = std::process::Command::new("ip")
16377        .args(["-6", "addr", "show"])
16378        .output()
16379    {
16380        out.push_str(&String::from_utf8_lossy(&o.stdout));
16381    }
16382    out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
16383    if let Ok(o) = std::process::Command::new("ip")
16384        .args(["-6", "route"])
16385        .output()
16386    {
16387        out.push_str(&String::from_utf8_lossy(&o.stdout));
16388    }
16389    Ok(out)
16390}
16391
16392// ── TCP Parameters ──────────────────────────────────────────────────────────
16393
16394#[cfg(windows)]
16395fn inspect_tcp_params() -> Result<String, String> {
16396    let script = r#"
16397$result = [System.Text.StringBuilder]::new()
16398
16399# Autotuning and global TCP settings
16400$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
16401try {
16402    $global = netsh interface tcp show global
16403    foreach ($line in $global) {
16404        $l = $line.Trim()
16405        if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
16406            $result.AppendLine("  $l") | Out-Null
16407        }
16408    }
16409} catch {
16410    $result.AppendLine("  Could not retrieve TCP global settings.") | Out-Null
16411}
16412
16413# Supplemental params via Get-NetTCPSetting
16414$result.AppendLine("") | Out-Null
16415$result.AppendLine("=== TCP settings profiles ===") | Out-Null
16416try {
16417    $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
16418    foreach ($s in $tcpSettings) {
16419        $result.AppendLine("  Profile: $($s.SettingName)") | Out-Null
16420        $result.AppendLine("    CongestionProvider:      $($s.CongestionProvider)") | Out-Null
16421        $result.AppendLine("    InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
16422        $result.AppendLine("    AutoTuningLevelLocal:    $($s.AutoTuningLevelLocal)") | Out-Null
16423        $result.AppendLine("    ScalingHeuristics:       $($s.ScalingHeuristics)") | Out-Null
16424        $result.AppendLine("    DynamicPortRangeStart:   $($s.DynamicPortRangeStartPort)") | Out-Null
16425        $result.AppendLine("    DynamicPortRangeEnd:     $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
16426        $result.AppendLine("") | Out-Null
16427    }
16428} catch {
16429    $result.AppendLine("  Get-NetTCPSetting unavailable.") | Out-Null
16430}
16431
16432# Chimney offload state
16433$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
16434try {
16435    $chimney = netsh interface tcp show chimney
16436    $result.AppendLine(($chimney -join "`n  ")) | Out-Null
16437} catch {
16438    $result.AppendLine("  Could not retrieve chimney state.") | Out-Null
16439}
16440
16441# ECN state
16442$result.AppendLine("") | Out-Null
16443$result.AppendLine("=== ECN capability ===") | Out-Null
16444try {
16445    $ecn = netsh interface tcp show ecncapability
16446    $result.AppendLine(($ecn -join "`n  ")) | Out-Null
16447} catch {
16448    $result.AppendLine("  Could not retrieve ECN state.") | Out-Null
16449}
16450
16451# Findings
16452$findings = [System.Collections.Generic.List[string]]::new()
16453try {
16454    $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
16455    if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
16456        $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
16457    }
16458    if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
16459        $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
16460    }
16461} catch {}
16462
16463$result.AppendLine("") | Out-Null
16464$result.AppendLine("=== Findings ===") | Out-Null
16465if ($findings.Count -eq 0) {
16466    $result.AppendLine("- TCP parameters look normal.") | Out-Null
16467} else {
16468    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16469}
16470
16471Write-Output $result.ToString()
16472"#;
16473    let out = run_powershell(script)?;
16474    Ok(format!("Host inspection: tcp_params\n\n{out}"))
16475}
16476
16477#[cfg(not(windows))]
16478fn inspect_tcp_params() -> Result<String, String> {
16479    let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
16480    for key in &[
16481        "net.ipv4.tcp_congestion_control",
16482        "net.ipv4.tcp_rmem",
16483        "net.ipv4.tcp_wmem",
16484        "net.ipv4.tcp_window_scaling",
16485        "net.ipv4.tcp_ecn",
16486        "net.ipv4.tcp_timestamps",
16487    ] {
16488        if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
16489            out.push_str(&format!(
16490                "  {}\n",
16491                String::from_utf8_lossy(&o.stdout).trim()
16492            ));
16493        }
16494    }
16495    Ok(out)
16496}
16497
16498// ── WLAN Profiles ───────────────────────────────────────────────────────────
16499
16500#[cfg(windows)]
16501fn inspect_wlan_profiles() -> Result<String, String> {
16502    let script = r#"
16503$result = [System.Text.StringBuilder]::new()
16504
16505# List all saved profiles
16506$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
16507try {
16508    $profilesRaw = netsh wlan show profiles
16509    $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16510        $_.Matches[0].Groups[1].Value.Trim()
16511    }
16512
16513    if (-not $profiles) {
16514        $result.AppendLine("  No saved wireless profiles found.") | Out-Null
16515    } else {
16516        foreach ($p in $profiles) {
16517            $result.AppendLine("") | Out-Null
16518            $result.AppendLine("  Profile: $p") | Out-Null
16519            # Get detail for each profile
16520            $detail = netsh wlan show profile name="$p" key=clear 2>$null
16521            $auth      = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16522            $cipher    = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
16523            $conn      = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
16524            $autoConn  = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
16525            if ($auth)     { $result.AppendLine("    Authentication:    $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16526            if ($cipher)   { $result.AppendLine("    Cipher:            $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16527            if ($conn)     { $result.AppendLine("    Connection mode:   $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16528            if ($autoConn) { $result.AppendLine("    Auto-connect:      $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16529        }
16530    }
16531} catch {
16532    $result.AppendLine("  netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
16533}
16534
16535# Currently connected SSID
16536$result.AppendLine("") | Out-Null
16537$result.AppendLine("=== Currently connected ===") | Out-Null
16538try {
16539    $conn = netsh wlan show interfaces
16540    $ssid   = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
16541    $bssid  = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
16542    $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
16543    $radio  = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
16544    if ($ssid)   { $result.AppendLine("  SSID:       $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16545    if ($bssid)  { $result.AppendLine("  BSSID:      $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16546    if ($signal) { $result.AppendLine("  Signal:     $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16547    if ($radio)  { $result.AppendLine("  Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16548    if (-not $ssid) { $result.AppendLine("  Not connected to any wireless network.") | Out-Null }
16549} catch {
16550    $result.AppendLine("  Could not query wireless interface state.") | Out-Null
16551}
16552
16553# Findings
16554$findings = [System.Collections.Generic.List[string]]::new()
16555try {
16556    $allDetail = netsh wlan show profiles 2>$null
16557    $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16558        $_.Matches[0].Groups[1].Value.Trim()
16559    }
16560    foreach ($pn in $profileNames) {
16561        $det = netsh wlan show profile name="$pn" key=clear 2>$null
16562        $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16563        if ($authLine) {
16564            $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
16565            if ($authVal -match 'Open|WEP|None') {
16566                $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
16567            }
16568        }
16569    }
16570} catch {}
16571
16572$result.AppendLine("") | Out-Null
16573$result.AppendLine("=== Findings ===") | Out-Null
16574if ($findings.Count -eq 0) {
16575    $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
16576} else {
16577    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16578}
16579
16580Write-Output $result.ToString()
16581"#;
16582    let out = run_powershell(script)?;
16583    Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
16584}
16585
16586#[cfg(not(windows))]
16587fn inspect_wlan_profiles() -> Result<String, String> {
16588    let mut out =
16589        String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
16590    // Try nmcli (NetworkManager)
16591    if let Ok(o) = std::process::Command::new("nmcli")
16592        .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
16593        .output()
16594    {
16595        for line in String::from_utf8_lossy(&o.stdout).lines() {
16596            if line.contains("wireless") || line.contains("wifi") {
16597                out.push_str(&format!("  {line}\n"));
16598            }
16599        }
16600    } else {
16601        out.push_str("  nmcli not available.\n");
16602    }
16603    Ok(out)
16604}
16605
16606// ── IPSec ───────────────────────────────────────────────────────────────────
16607
16608#[cfg(windows)]
16609fn inspect_ipsec() -> Result<String, String> {
16610    let script = r#"
16611$result = [System.Text.StringBuilder]::new()
16612
16613# IPSec rules (firewall-integrated)
16614$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
16615try {
16616    $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
16617    if ($rules) {
16618        foreach ($r in $rules) {
16619            $result.AppendLine("  [$($r.DisplayName)]") | Out-Null
16620            $result.AppendLine("    Mode:       $($r.Mode)") | Out-Null
16621            $result.AppendLine("    Action:     $($r.Action)") | Out-Null
16622            $result.AppendLine("    InProfile:  $($r.Profile)") | Out-Null
16623        }
16624    } else {
16625        $result.AppendLine("  No enabled IPSec connection security rules found.") | Out-Null
16626    }
16627} catch {
16628    $result.AppendLine("  Get-NetIPsecRule unavailable.") | Out-Null
16629}
16630
16631# Active main-mode SAs
16632$result.AppendLine("") | Out-Null
16633$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
16634try {
16635    $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
16636    if ($mmSAs) {
16637        foreach ($sa in $mmSAs) {
16638            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
16639            $result.AppendLine("    AuthMethod: $($sa.LocalFirstId)  Cipher: $($sa.Cipher)") | Out-Null
16640        }
16641    } else {
16642        $result.AppendLine("  No active main-mode IPSec SAs.") | Out-Null
16643    }
16644} catch {
16645    $result.AppendLine("  Get-NetIPsecMainModeSA unavailable.") | Out-Null
16646}
16647
16648# Active quick-mode SAs
16649$result.AppendLine("") | Out-Null
16650$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
16651try {
16652    $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
16653    if ($qmSAs) {
16654        foreach ($sa in $qmSAs) {
16655            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
16656            $result.AppendLine("    Encapsulation: $($sa.EncapsulationMode)  Protocol: $($sa.TransportLayerProtocol)") | Out-Null
16657        }
16658    } else {
16659        $result.AppendLine("  No active quick-mode IPSec SAs.") | Out-Null
16660    }
16661} catch {
16662    $result.AppendLine("  Get-NetIPsecQuickModeSA unavailable.") | Out-Null
16663}
16664
16665# IKE service state
16666$result.AppendLine("") | Out-Null
16667$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
16668$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
16669if ($ikeAgentSvc) {
16670    $result.AppendLine("  PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
16671} else {
16672    $result.AppendLine("  PolicyAgent service not found.") | Out-Null
16673}
16674
16675# Findings
16676$findings = [System.Collections.Generic.List[string]]::new()
16677$mmSACount = 0
16678try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
16679if ($mmSACount -gt 0) {
16680    $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
16681}
16682
16683$result.AppendLine("") | Out-Null
16684$result.AppendLine("=== Findings ===") | Out-Null
16685if ($findings.Count -eq 0) {
16686    $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
16687} else {
16688    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16689}
16690
16691Write-Output $result.ToString()
16692"#;
16693    let out = run_powershell(script)?;
16694    Ok(format!("Host inspection: ipsec\n\n{out}"))
16695}
16696
16697#[cfg(not(windows))]
16698fn inspect_ipsec() -> Result<String, String> {
16699    let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
16700    if let Ok(o) = std::process::Command::new("ip")
16701        .args(["xfrm", "state"])
16702        .output()
16703    {
16704        let body = String::from_utf8_lossy(&o.stdout);
16705        if body.trim().is_empty() {
16706            out.push_str("  No active IPSec SAs.\n");
16707        } else {
16708            out.push_str(&body);
16709        }
16710    }
16711    out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
16712    if let Ok(o) = std::process::Command::new("ip")
16713        .args(["xfrm", "policy"])
16714        .output()
16715    {
16716        let body = String::from_utf8_lossy(&o.stdout);
16717        if body.trim().is_empty() {
16718            out.push_str("  No IPSec policies.\n");
16719        } else {
16720            out.push_str(&body);
16721        }
16722    }
16723    Ok(out)
16724}
16725
16726// ── NetBIOS ──────────────────────────────────────────────────────────────────
16727
16728#[cfg(windows)]
16729fn inspect_netbios() -> Result<String, String> {
16730    let script = r#"
16731$result = [System.Text.StringBuilder]::new()
16732
16733# NetBIOS node type and WINS per adapter
16734$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
16735try {
16736    $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16737        Where-Object { $_.IPEnabled -eq $true }
16738    foreach ($a in $adapters) {
16739        $nodeType = switch ($a.TcpipNetbiosOptions) {
16740            0 { "EnableNetBIOSViaDHCP" }
16741            1 { "Enabled" }
16742            2 { "Disabled" }
16743            default { "Unknown ($($a.TcpipNetbiosOptions))" }
16744        }
16745        $result.AppendLine("  [$($a.Description)]") | Out-Null
16746        $result.AppendLine("    NetBIOS over TCP/IP: $nodeType") | Out-Null
16747        if ($a.WINSPrimaryServer) {
16748            $result.AppendLine("    WINS Primary:        $($a.WINSPrimaryServer)") | Out-Null
16749        }
16750        if ($a.WINSSecondaryServer) {
16751            $result.AppendLine("    WINS Secondary:      $($a.WINSSecondaryServer)") | Out-Null
16752        }
16753    }
16754} catch {
16755    $result.AppendLine("  Could not query NetBIOS adapter config.") | Out-Null
16756}
16757
16758# nbtstat -n — registered local NetBIOS names
16759$result.AppendLine("") | Out-Null
16760$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
16761try {
16762    $nbt = nbtstat -n 2>$null
16763    foreach ($line in $nbt) {
16764        $l = $line.Trim()
16765        if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
16766            $result.AppendLine("  $l") | Out-Null
16767        }
16768    }
16769} catch {
16770    $result.AppendLine("  nbtstat not available.") | Out-Null
16771}
16772
16773# NetBIOS session table
16774$result.AppendLine("") | Out-Null
16775$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
16776try {
16777    $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
16778    if ($sessions) {
16779        foreach ($s in $sessions) { $result.AppendLine("  $($s.Trim())") | Out-Null }
16780    } else {
16781        $result.AppendLine("  No active NetBIOS sessions.") | Out-Null
16782    }
16783} catch {
16784    $result.AppendLine("  Could not query NetBIOS sessions.") | Out-Null
16785}
16786
16787# Findings
16788$findings = [System.Collections.Generic.List[string]]::new()
16789try {
16790    $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16791        Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
16792    if ($enabled) {
16793        $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
16794    }
16795    $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16796        Where-Object { $_.WINSPrimaryServer }
16797    if ($wins) {
16798        $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
16799    }
16800} catch {}
16801
16802$result.AppendLine("") | Out-Null
16803$result.AppendLine("=== Findings ===") | Out-Null
16804if ($findings.Count -eq 0) {
16805    $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
16806} else {
16807    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16808}
16809
16810Write-Output $result.ToString()
16811"#;
16812    let out = run_powershell(script)?;
16813    Ok(format!("Host inspection: netbios\n\n{out}"))
16814}
16815
16816#[cfg(not(windows))]
16817fn inspect_netbios() -> Result<String, String> {
16818    let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
16819    if let Ok(o) = std::process::Command::new("nmblookup")
16820        .arg("-A")
16821        .arg("localhost")
16822        .output()
16823    {
16824        out.push_str(&String::from_utf8_lossy(&o.stdout));
16825    } else {
16826        out.push_str("  nmblookup not available (Samba not installed).\n");
16827    }
16828    Ok(out)
16829}
16830
16831// ── NIC Teaming ──────────────────────────────────────────────────────────────
16832
16833#[cfg(windows)]
16834fn inspect_nic_teaming() -> Result<String, String> {
16835    let script = r#"
16836$result = [System.Text.StringBuilder]::new()
16837
16838# Team inventory
16839$result.AppendLine("=== NIC teams ===") | Out-Null
16840try {
16841    $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
16842    if ($teams) {
16843        foreach ($t in $teams) {
16844            $result.AppendLine("  Team: $($t.Name)") | Out-Null
16845            $result.AppendLine("    Mode:            $($t.TeamingMode)") | Out-Null
16846            $result.AppendLine("    LB Algorithm:    $($t.LoadBalancingAlgorithm)") | Out-Null
16847            $result.AppendLine("    Status:          $($t.Status)") | Out-Null
16848            $result.AppendLine("    Members:         $($t.Members -join ', ')") | Out-Null
16849            $result.AppendLine("    VLANs:           $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
16850        }
16851    } else {
16852        $result.AppendLine("  No NIC teams configured on this machine.") | Out-Null
16853    }
16854} catch {
16855    $result.AppendLine("  Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
16856}
16857
16858# Team members detail
16859$result.AppendLine("") | Out-Null
16860$result.AppendLine("=== Team member detail ===") | Out-Null
16861try {
16862    $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
16863    if ($members) {
16864        foreach ($m in $members) {
16865            $result.AppendLine("  [$($m.Team)] $($m.Name)  Role=$($m.AdministrativeMode)  Status=$($m.OperationalStatus)") | Out-Null
16866        }
16867    } else {
16868        $result.AppendLine("  No team members found.") | Out-Null
16869    }
16870} catch {
16871    $result.AppendLine("  Could not query team members.") | Out-Null
16872}
16873
16874# Findings
16875$findings = [System.Collections.Generic.List[string]]::new()
16876try {
16877    $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
16878    if ($degraded) {
16879        foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
16880    }
16881    $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
16882    if ($downMembers) {
16883        foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
16884    }
16885} catch {}
16886
16887$result.AppendLine("") | Out-Null
16888$result.AppendLine("=== Findings ===") | Out-Null
16889if ($findings.Count -eq 0) {
16890    $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
16891} else {
16892    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16893}
16894
16895Write-Output $result.ToString()
16896"#;
16897    let out = run_powershell(script)?;
16898    Ok(format!("Host inspection: nic_teaming\n\n{out}"))
16899}
16900
16901#[cfg(not(windows))]
16902fn inspect_nic_teaming() -> Result<String, String> {
16903    let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
16904    if let Ok(o) = std::process::Command::new("cat")
16905        .arg("/proc/net/bonding/bond0")
16906        .output()
16907    {
16908        if o.status.success() {
16909            out.push_str(&String::from_utf8_lossy(&o.stdout));
16910        } else {
16911            out.push_str("  No bond0 interface found.\n");
16912        }
16913    }
16914    if let Ok(o) = std::process::Command::new("ip")
16915        .args(["link", "show", "type", "bond"])
16916        .output()
16917    {
16918        let body = String::from_utf8_lossy(&o.stdout);
16919        if !body.trim().is_empty() {
16920            out.push_str("\n=== Bond links (ip link) ===\n");
16921            out.push_str(&body);
16922        }
16923    }
16924    Ok(out)
16925}
16926
16927// ── SNMP ─────────────────────────────────────────────────────────────────────
16928
16929#[cfg(windows)]
16930fn inspect_snmp() -> Result<String, String> {
16931    let script = r#"
16932$result = [System.Text.StringBuilder]::new()
16933
16934# SNMP service state
16935$result.AppendLine("=== SNMP service state ===") | Out-Null
16936$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
16937if ($svc) {
16938    $result.AppendLine("  SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
16939} else {
16940    $result.AppendLine("  SNMP Agent service not installed.") | Out-Null
16941}
16942
16943$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
16944if ($svcTrap) {
16945    $result.AppendLine("  SNMP Trap service:  $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
16946}
16947
16948# Community strings (presence only — values redacted)
16949$result.AppendLine("") | Out-Null
16950$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
16951try {
16952    $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
16953    if ($communities) {
16954        $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
16955        if ($names) {
16956            foreach ($n in $names) {
16957                $result.AppendLine("  Community: '$n'  (value redacted)") | Out-Null
16958            }
16959        } else {
16960            $result.AppendLine("  No community strings configured.") | Out-Null
16961        }
16962    } else {
16963        $result.AppendLine("  Registry key not found (SNMP may not be configured).") | Out-Null
16964    }
16965} catch {
16966    $result.AppendLine("  Could not read community strings (SNMP not configured or access denied).") | Out-Null
16967}
16968
16969# Permitted managers
16970$result.AppendLine("") | Out-Null
16971$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
16972try {
16973    $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
16974    if ($managers) {
16975        $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
16976        if ($mgrs) {
16977            foreach ($m in $mgrs) { $result.AppendLine("  $m") | Out-Null }
16978        } else {
16979            $result.AppendLine("  No permitted managers configured (accepts from any host).") | Out-Null
16980        }
16981    } else {
16982        $result.AppendLine("  No manager restrictions configured.") | Out-Null
16983    }
16984} catch {
16985    $result.AppendLine("  Could not read permitted managers.") | Out-Null
16986}
16987
16988# Findings
16989$findings = [System.Collections.Generic.List[string]]::new()
16990$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
16991if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
16992    $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
16993    try {
16994        $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
16995        $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
16996        if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
16997    } catch {}
16998}
16999
17000$result.AppendLine("") | Out-Null
17001$result.AppendLine("=== Findings ===") | Out-Null
17002if ($findings.Count -eq 0) {
17003    $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
17004} else {
17005    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17006}
17007
17008Write-Output $result.ToString()
17009"#;
17010    let out = run_powershell(script)?;
17011    Ok(format!("Host inspection: snmp\n\n{out}"))
17012}
17013
17014#[cfg(not(windows))]
17015fn inspect_snmp() -> Result<String, String> {
17016    let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
17017    for svc in &["snmpd", "snmp"] {
17018        if let Ok(o) = std::process::Command::new("systemctl")
17019            .args(["is-active", svc])
17020            .output()
17021        {
17022            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
17023            out.push_str(&format!("  {svc}: {status}\n"));
17024        }
17025    }
17026    out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
17027    if let Ok(o) = std::process::Command::new("grep")
17028        .args(["-i", "community", "/etc/snmp/snmpd.conf"])
17029        .output()
17030    {
17031        if o.status.success() {
17032            for line in String::from_utf8_lossy(&o.stdout).lines() {
17033                out.push_str(&format!("  {line}\n"));
17034            }
17035        } else {
17036            out.push_str("  /etc/snmp/snmpd.conf not found or no community lines.\n");
17037        }
17038    }
17039    Ok(out)
17040}
17041
17042// ── Port Test ─────────────────────────────────────────────────────────────────
17043
17044#[cfg(windows)]
17045fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17046    let target_host = host.unwrap_or("8.8.8.8");
17047    let target_port = port.unwrap_or(443);
17048
17049    let script = format!(
17050        r#"
17051$result = [System.Text.StringBuilder]::new()
17052$result.AppendLine("=== Port reachability test ===") | Out-Null
17053$result.AppendLine("  Target: {target_host}:{target_port}") | Out-Null
17054$result.AppendLine("") | Out-Null
17055
17056try {{
17057    $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
17058    if ($test) {{
17059        $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
17060        $result.AppendLine("  Result:          $status") | Out-Null
17061        $result.AppendLine("  Remote address:  $($test.RemoteAddress)") | Out-Null
17062        $result.AppendLine("  Remote port:     $($test.RemotePort)") | Out-Null
17063        if ($test.PingSucceeded) {{
17064            $result.AppendLine("  ICMP ping:       Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
17065        }} else {{
17066            $result.AppendLine("  ICMP ping:       Failed (host may block ICMP)") | Out-Null
17067        }}
17068        $result.AppendLine("  Interface used:  $($test.InterfaceAlias)") | Out-Null
17069        $result.AppendLine("  Source address:  $($test.SourceAddress.IPAddress)") | Out-Null
17070
17071        $result.AppendLine("") | Out-Null
17072        $result.AppendLine("=== Findings ===") | Out-Null
17073        if ($test.TcpTestSucceeded) {{
17074            $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
17075        }} else {{
17076            $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
17077            $result.AppendLine("  Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
17078        }}
17079    }}
17080}} catch {{
17081    $result.AppendLine("  Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
17082}}
17083
17084Write-Output $result.ToString()
17085"#
17086    );
17087    let out = run_powershell(&script)?;
17088    Ok(format!("Host inspection: port_test\n\n{out}"))
17089}
17090
17091#[cfg(not(windows))]
17092fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
17093    let target_host = host.unwrap_or("8.8.8.8");
17094    let target_port = port.unwrap_or(443);
17095    let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n  Target: {target_host}:{target_port}\n\n");
17096    // nc -zv with timeout
17097    let nc = std::process::Command::new("nc")
17098        .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
17099        .output();
17100    match nc {
17101        Ok(o) => {
17102            let stderr = String::from_utf8_lossy(&o.stderr);
17103            let stdout = String::from_utf8_lossy(&o.stdout);
17104            let body = if !stdout.trim().is_empty() {
17105                stdout.as_ref()
17106            } else {
17107                stderr.as_ref()
17108            };
17109            out.push_str(&format!("  {}\n", body.trim()));
17110            out.push_str("\n=== Findings ===\n");
17111            if o.status.success() {
17112                out.push_str(&format!("- Port {target_port} on {target_host} is OPEN.\n"));
17113            } else {
17114                out.push_str(&format!(
17115                    "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
17116                ));
17117            }
17118        }
17119        Err(e) => out.push_str(&format!("  nc not available: {e}\n")),
17120    }
17121    Ok(out)
17122}
17123
17124// ── Network Profile ───────────────────────────────────────────────────────────
17125
17126#[cfg(windows)]
17127fn inspect_network_profile() -> Result<String, String> {
17128    let script = r#"
17129$result = [System.Text.StringBuilder]::new()
17130
17131$result.AppendLine("=== Network location profiles ===") | Out-Null
17132try {
17133    $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
17134    if ($profiles) {
17135        foreach ($p in $profiles) {
17136            $result.AppendLine("  Interface: $($p.InterfaceAlias)") | Out-Null
17137            $result.AppendLine("    Network name:    $($p.Name)") | Out-Null
17138            $result.AppendLine("    Category:        $($p.NetworkCategory)") | Out-Null
17139            $result.AppendLine("    IPv4 conn:       $($p.IPv4Connectivity)") | Out-Null
17140            $result.AppendLine("    IPv6 conn:       $($p.IPv6Connectivity)") | Out-Null
17141            $result.AppendLine("") | Out-Null
17142        }
17143    } else {
17144        $result.AppendLine("  No network connection profiles found.") | Out-Null
17145    }
17146} catch {
17147    $result.AppendLine("  Could not query network profiles.") | Out-Null
17148}
17149
17150# Findings
17151$findings = [System.Collections.Generic.List[string]]::new()
17152try {
17153    $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
17154    if ($pub) {
17155        foreach ($p in $pub) {
17156            $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
17157        }
17158    }
17159    $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
17160    if ($domain) {
17161        foreach ($d in $domain) {
17162            $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
17163        }
17164    }
17165} catch {}
17166
17167$result.AppendLine("=== Findings ===") | Out-Null
17168if ($findings.Count -eq 0) {
17169    $result.AppendLine("- Network profiles look normal.") | Out-Null
17170} else {
17171    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
17172}
17173
17174Write-Output $result.ToString()
17175"#;
17176    let out = run_powershell(script)?;
17177    Ok(format!("Host inspection: network_profile\n\n{out}"))
17178}
17179
17180#[cfg(not(windows))]
17181fn inspect_network_profile() -> Result<String, String> {
17182    let mut out = String::from(
17183        "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
17184    );
17185    if let Ok(o) = std::process::Command::new("nmcli")
17186        .args([
17187            "-t",
17188            "-f",
17189            "NAME,TYPE,STATE,DEVICE",
17190            "connection",
17191            "show",
17192            "--active",
17193        ])
17194        .output()
17195    {
17196        out.push_str(&String::from_utf8_lossy(&o.stdout));
17197    } else {
17198        out.push_str("  nmcli not available.\n");
17199    }
17200    Ok(out)
17201}