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        other => Err(format!(
241            "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.",
242            other
243        )),
244
245    };
246
247    result.map(|body| annotate_privilege_limited_output(topic.as_str(), body))
248}
249
250fn annotate_privilege_limited_output(topic: &str, body: String) -> String {
251    let Some(scope) = admin_sensitive_topic_scope(topic) else {
252        return body;
253    };
254    let lower = body.to_lowercase();
255    let privilege_limited = lower.contains("access denied")
256        || lower.contains("administrator privilege is required")
257        || lower.contains("administrator privileges required")
258        || lower.contains("requires administrator")
259        || lower.contains("requires elevation")
260        || lower.contains("non-admin session")
261        || lower.contains("could not be fully determined from this session");
262    if !privilege_limited || lower.contains("=== elevation note ===") {
263        return body;
264    }
265
266    let mut annotated = body;
267    annotated.push_str("\n=== Elevation note ===\n");
268    annotated.push_str("- Hematite should stay non-admin by default.\n");
269    annotated.push_str(
270        "- This result may be partial because Windows restricted one or more read-only provider calls in the current session.\n",
271    );
272    annotated.push_str(&format!(
273        "- Rerun Hematite as Administrator only if you need a definitive {scope} answer.\n"
274    ));
275    annotated
276}
277
278fn admin_sensitive_topic_scope(topic: &str) -> Option<&'static str> {
279    match topic {
280        "tpm" | "secure_boot" | "uefi" | "tpm_status" | "secureboot" | "firmware_security" => {
281            Some("TPM / Secure Boot / firmware")
282        }
283        "gpo" | "group_policy" | "applied_policies" => Some("Group Policy"),
284        "audit_policy" | "audit" | "auditpol" => Some("audit policy"),
285        "winrm" | "remote_management" | "psremoting" => Some("WinRM"),
286        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => Some("BitLocker"),
287        "windows_features" | "optional_features" | "installed_features" | "features" => {
288            Some("Windows Features")
289        }
290        "udp_ports" | "udp_listeners" | "udp" => Some("UDP listener"),
291        _ => None,
292    }
293}
294
295#[cfg(test)]
296mod privilege_hint_tests {
297    use super::annotate_privilege_limited_output;
298
299    #[test]
300    fn annotate_privilege_limited_output_only_tags_admin_sensitive_topics() {
301        let body = "Host inspection: network\nError: Access denied.\n".to_string();
302        let annotated = annotate_privilege_limited_output("network", body.clone());
303        assert_eq!(annotated, body);
304    }
305
306    #[test]
307    fn annotate_privilege_limited_output_adds_targeted_note_for_tpm() {
308        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();
309        let annotated = annotate_privilege_limited_output("tpm", body);
310        assert!(annotated.contains("=== Elevation note ==="));
311        assert!(annotated.contains("stay non-admin by default"));
312        assert!(annotated.contains("definitive TPM / Secure Boot / firmware answer"));
313    }
314}
315
316#[cfg(test)]
317mod event_query_tests {
318    use super::is_event_query_no_results_message;
319
320#[cfg(target_os = "windows")]
321    #[test]
322    fn treats_windows_no_results_message_as_empty_query() {
323        assert!(is_event_query_no_results_message(
324            "No events were found that match the specified selection criteria."
325        ));
326    }
327
328    #[cfg(target_os = "windows")]
329    #[test]
330    fn does_not_treat_real_errors_as_empty_query() {
331        assert!(!is_event_query_no_results_message("Access is denied."));
332    }
333}
334
335fn parse_max_entries(args: &Value) -> usize {
336    args.get("max_entries")
337        .and_then(|v| v.as_u64())
338        .map(|n| n as usize)
339        .unwrap_or(DEFAULT_MAX_ENTRIES)
340        .clamp(1, MAX_ENTRIES_CAP)
341}
342
343fn parse_port_filter(args: &Value) -> Option<u16> {
344    args.get("port")
345        .and_then(|v| v.as_u64())
346        .and_then(|n| u16::try_from(n).ok())
347}
348
349fn parse_name_filter(args: &Value) -> Option<String> {
350    args.get("name")
351        .and_then(|v| v.as_str())
352        .map(str::trim)
353        .filter(|value| !value.is_empty())
354        .map(|value| value.to_string())
355}
356
357fn parse_lookback_hours(args: &Value) -> Option<u32> {
358    args.get("lookback_hours")
359        .and_then(|v| v.as_u64())
360        .map(|n| n as u32)
361}
362
363fn parse_issue_text(args: &Value) -> Option<String> {
364    args.get("issue")
365        .and_then(|v| v.as_str())
366        .map(str::trim)
367        .filter(|value| !value.is_empty())
368        .map(|value| value.to_string())
369}
370
371#[cfg(target_os = "windows")]
372fn is_event_query_no_results_message(message: &str) -> bool {
373    let lower = message.to_ascii_lowercase();
374    lower.contains("no events were found")
375        || lower.contains("no events match the specified selection criteria")
376}
377
378fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
379    match args.get("path").and_then(|v| v.as_str()) {
380        Some(raw_path) => resolve_path(raw_path),
381        None => {
382            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
383        }
384    }
385}
386
387fn inspect_summary(max_entries: usize) -> Result<String, String> {
388    let current_dir =
389        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
390    let workspace_root = crate::tools::file_ops::workspace_root();
391    let workspace_mode = workspace_mode_label(&workspace_root);
392    let path_stats = analyze_path_env();
393    let toolchains = collect_toolchains();
394
395    let mut out = String::from("Host inspection: summary\n\n");
396    out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
397    out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
398    out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
399    out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
400    out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
401    out.push_str(&format!(
402        "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
403        path_stats.total_entries,
404        path_stats.unique_entries,
405        path_stats.duplicate_entries.len(),
406        path_stats.missing_entries.len()
407    ));
408
409    if toolchains.found.is_empty() {
410        out.push_str(
411            "- Toolchains found: none of the common developer tools were detected on PATH\n",
412        );
413    } else {
414        out.push_str("- Toolchains found:\n");
415        for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
416            out.push_str(&format!("  - {}: {}\n", label, version));
417        }
418        if toolchains.found.len() > max_entries.min(8) {
419            out.push_str(&format!(
420                "  - ... {} more found tools omitted\n",
421                toolchains.found.len() - max_entries.min(8)
422            ));
423        }
424    }
425
426    if !toolchains.missing.is_empty() {
427        out.push_str(&format!(
428            "- Common tools not detected on PATH: {}\n",
429            toolchains.missing.join(", ")
430        ));
431    }
432
433    for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
434        match path {
435            Some(path) if path.exists() => match count_top_level_items(&path) {
436                Ok(count) => out.push_str(&format!(
437                    "- {}: {} top-level items at {}\n",
438                    label,
439                    count,
440                    path.display()
441                )),
442                Err(e) => out.push_str(&format!(
443                    "- {}: exists at {} but could not inspect ({})\n",
444                    label,
445                    path.display(),
446                    e
447                )),
448            },
449            Some(path) => out.push_str(&format!(
450                "- {}: expected at {} but not found\n",
451                label,
452                path.display()
453            )),
454            None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
455        }
456    }
457
458    Ok(out.trim_end().to_string())
459}
460
461fn inspect_toolchains() -> Result<String, String> {
462    let report = collect_toolchains();
463    let mut out = String::from("Host inspection: toolchains\n\n");
464
465    if report.found.is_empty() {
466        out.push_str("- No common developer tools were detected on PATH.");
467    } else {
468        out.push_str("Detected developer tools:\n");
469        for (label, version) in report.found {
470            out.push_str(&format!("- {}: {}\n", label, version));
471        }
472    }
473
474    if !report.missing.is_empty() {
475        out.push_str("\nNot detected on PATH:\n");
476        for label in report.missing {
477            out.push_str(&format!("- {}\n", label));
478        }
479    }
480
481    Ok(out.trim_end().to_string())
482}
483
484fn inspect_path(max_entries: usize) -> Result<String, String> {
485    let path_stats = analyze_path_env();
486    let mut out = String::from("Host inspection: PATH\n\n");
487    out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
488    out.push_str(&format!(
489        "- Unique entries: {}\n",
490        path_stats.unique_entries
491    ));
492    out.push_str(&format!(
493        "- Duplicate entries: {}\n",
494        path_stats.duplicate_entries.len()
495    ));
496    out.push_str(&format!(
497        "- Missing paths: {}\n",
498        path_stats.missing_entries.len()
499    ));
500
501    out.push_str("\nPATH entries:\n");
502    for entry in path_stats.entries.iter().take(max_entries) {
503        out.push_str(&format!("- {}\n", entry));
504    }
505    if path_stats.entries.len() > max_entries {
506        out.push_str(&format!(
507            "- ... {} more entries omitted\n",
508            path_stats.entries.len() - max_entries
509        ));
510    }
511
512    if !path_stats.duplicate_entries.is_empty() {
513        out.push_str("\nDuplicate entries:\n");
514        for entry in path_stats.duplicate_entries.iter().take(max_entries) {
515            out.push_str(&format!("- {}\n", entry));
516        }
517        if path_stats.duplicate_entries.len() > max_entries {
518            out.push_str(&format!(
519                "- ... {} more duplicates omitted\n",
520                path_stats.duplicate_entries.len() - max_entries
521            ));
522        }
523    }
524
525    if !path_stats.missing_entries.is_empty() {
526        out.push_str("\nMissing directories:\n");
527        for entry in path_stats.missing_entries.iter().take(max_entries) {
528            out.push_str(&format!("- {}\n", entry));
529        }
530        if path_stats.missing_entries.len() > max_entries {
531            out.push_str(&format!(
532                "- ... {} more missing entries omitted\n",
533                path_stats.missing_entries.len() - max_entries
534            ));
535        }
536    }
537
538    Ok(out.trim_end().to_string())
539}
540
541fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
542    let path_stats = analyze_path_env();
543    let toolchains = collect_toolchains();
544    let package_managers = collect_package_managers();
545    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
546
547    let mut out = String::from("Host inspection: env_doctor\n\n");
548    out.push_str(&format!(
549        "- PATH health: {} duplicates, {} missing entries\n",
550        path_stats.duplicate_entries.len(),
551        path_stats.missing_entries.len()
552    ));
553    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
554    out.push_str(&format!(
555        "- Package managers found: {}\n",
556        package_managers.found.len()
557    ));
558
559    if !package_managers.found.is_empty() {
560        out.push_str("\nPackage managers:\n");
561        for (label, version) in package_managers.found.iter().take(max_entries) {
562            out.push_str(&format!("- {}: {}\n", label, version));
563        }
564        if package_managers.found.len() > max_entries {
565            out.push_str(&format!(
566                "- ... {} more package managers omitted\n",
567                package_managers.found.len() - max_entries
568            ));
569        }
570    }
571
572    if !path_stats.duplicate_entries.is_empty() {
573        out.push_str("\nDuplicate PATH entries:\n");
574        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
575            out.push_str(&format!("- {}\n", entry));
576        }
577        if path_stats.duplicate_entries.len() > max_entries.min(5) {
578            out.push_str(&format!(
579                "- ... {} more duplicate entries omitted\n",
580                path_stats.duplicate_entries.len() - max_entries.min(5)
581            ));
582        }
583    }
584
585    if !path_stats.missing_entries.is_empty() {
586        out.push_str("\nMissing PATH entries:\n");
587        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
588            out.push_str(&format!("- {}\n", entry));
589        }
590        if path_stats.missing_entries.len() > max_entries.min(5) {
591            out.push_str(&format!(
592                "- ... {} more missing entries omitted\n",
593                path_stats.missing_entries.len() - max_entries.min(5)
594            ));
595        }
596    }
597
598    if !findings.is_empty() {
599        out.push_str("\nFindings:\n");
600        for finding in findings.iter().take(max_entries.max(5)) {
601            out.push_str(&format!("- {}\n", finding));
602        }
603        if findings.len() > max_entries.max(5) {
604            out.push_str(&format!(
605                "- ... {} more findings omitted\n",
606                findings.len() - max_entries.max(5)
607            ));
608        }
609    } else {
610        out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
611    }
612
613    out.push_str(
614        "\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.",
615    );
616
617    Ok(out.trim_end().to_string())
618}
619
620#[derive(Clone, Copy, Debug, Eq, PartialEq)]
621enum FixPlanKind {
622    EnvPath,
623    PortConflict,
624    LmStudio,
625    DriverInstall,
626    GroupPolicy,
627    FirewallRule,
628    SshKey,
629    WslSetup,
630    ServiceConfig,
631    WindowsActivation,
632    RegistryEdit,
633    ScheduledTaskCreate,
634    DiskCleanup,
635    DnsResolution,
636    Generic,
637}
638
639async fn inspect_fix_plan(
640    issue: Option<String>,
641    port_filter: Option<u16>,
642    max_entries: usize,
643) -> Result<String, String> {
644    let issue = issue.unwrap_or_else(|| {
645        "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
646            .to_string()
647    });
648    let plan_kind = classify_fix_plan_kind(&issue, port_filter);
649    match plan_kind {
650        FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
651        FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
652        FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
653        FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
654        FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
655        FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
656        FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
657        FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
658        FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
659        FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
660        FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
661        FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
662        FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
663        FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
664        FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
665    }
666}
667
668fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
669    let lower = issue.to_ascii_lowercase();
670    // FirewallRule must be checked before PortConflict — "open port 80 in the firewall"
671    // is firewall rule creation, not a port ownership conflict.
672    if lower.contains("firewall rule")
673        || lower.contains("inbound rule")
674        || lower.contains("outbound rule")
675        || (lower.contains("firewall")
676            && (lower.contains("allow")
677                || lower.contains("block")
678                || lower.contains("create")
679                || lower.contains("open")))
680    {
681        FixPlanKind::FirewallRule
682    } else if port_filter.is_some()
683        || lower.contains("port ")
684        || lower.contains("address already in use")
685        || lower.contains("already in use")
686        || lower.contains("what owns port")
687        || lower.contains("listening on port")
688    {
689        FixPlanKind::PortConflict
690    } else if lower.contains("lm studio")
691        || lower.contains("localhost:1234")
692        || lower.contains("/v1/models")
693        || lower.contains("no coding model loaded")
694        || lower.contains("embedding model")
695        || lower.contains("server on port 1234")
696        || lower.contains("runtime refresh")
697    {
698        FixPlanKind::LmStudio
699    } else if lower.contains("driver")
700        || lower.contains("gpu driver")
701        || lower.contains("nvidia driver")
702        || lower.contains("amd driver")
703        || lower.contains("install driver")
704        || lower.contains("update driver")
705    {
706        FixPlanKind::DriverInstall
707    } else if lower.contains("group policy")
708        || lower.contains("gpedit")
709        || lower.contains("local policy")
710        || lower.contains("secpol")
711        || lower.contains("administrative template")
712    {
713        FixPlanKind::GroupPolicy
714    } else if lower.contains("ssh key")
715        || lower.contains("ssh-keygen")
716        || lower.contains("generate ssh")
717        || lower.contains("authorized_keys")
718        || lower.contains("id_rsa")
719        || lower.contains("id_ed25519")
720    {
721        FixPlanKind::SshKey
722    } else if lower.contains("wsl")
723        || lower.contains("windows subsystem for linux")
724        || lower.contains("install ubuntu")
725        || lower.contains("install linux on windows")
726        || lower.contains("wsl2")
727    {
728        FixPlanKind::WslSetup
729    } else if lower.contains("service")
730        && (lower.contains("start ")
731            || lower.contains("stop ")
732            || lower.contains("restart ")
733            || lower.contains("enable ")
734            || lower.contains("disable ")
735            || lower.contains("configure service"))
736    {
737        FixPlanKind::ServiceConfig
738    } else if lower.contains("activate windows")
739        || lower.contains("windows activation")
740        || lower.contains("product key")
741        || lower.contains("kms")
742        || lower.contains("not activated")
743    {
744        FixPlanKind::WindowsActivation
745    } else if lower.contains("registry")
746        || lower.contains("regedit")
747        || lower.contains("hklm")
748        || lower.contains("hkcu")
749        || lower.contains("reg add")
750        || lower.contains("reg delete")
751        || lower.contains("registry key")
752    {
753        FixPlanKind::RegistryEdit
754    } else if lower.contains("scheduled task")
755        || lower.contains("task scheduler")
756        || lower.contains("schtasks")
757        || lower.contains("create task")
758        || lower.contains("run on startup")
759        || lower.contains("run on schedule")
760        || lower.contains("cron")
761    {
762        FixPlanKind::ScheduledTaskCreate
763    } else if lower.contains("disk cleanup")
764        || lower.contains("free up disk")
765        || lower.contains("free up space")
766        || lower.contains("clear cache")
767        || lower.contains("disk full")
768        || lower.contains("low disk space")
769        || lower.contains("reclaim space")
770    {
771        FixPlanKind::DiskCleanup
772    } else if lower.contains("cargo")
773        || lower.contains("rustc")
774        || lower.contains("path")
775        || lower.contains("package manager")
776        || lower.contains("package managers")
777        || lower.contains("toolchain")
778        || lower.contains("winget")
779        || lower.contains("choco")
780        || lower.contains("scoop")
781        || lower.contains("python")
782        || lower.contains("node")
783    {
784        FixPlanKind::EnvPath
785    } else if lower.contains("dns ")
786        || lower.contains("nameserver")
787        || lower.contains("cannot resolve")
788        || lower.contains("nslookup")
789        || lower.contains("flushdns")
790    {
791        FixPlanKind::DnsResolution
792    } else {
793        FixPlanKind::Generic
794    }
795}
796
797fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
798    let path_stats = analyze_path_env();
799    let toolchains = collect_toolchains();
800    let package_managers = collect_package_managers();
801    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
802    let found_tools = toolchains
803        .found
804        .iter()
805        .map(|(label, _)| label.as_str())
806        .collect::<HashSet<_>>();
807    let found_managers = package_managers
808        .found
809        .iter()
810        .map(|(label, _)| label.as_str())
811        .collect::<HashSet<_>>();
812
813    let mut out = String::from("Host inspection: fix_plan\n\n");
814    out.push_str(&format!("- Requested issue: {}\n", issue));
815    out.push_str("- Fix-plan type: environment/path\n");
816    out.push_str(&format!(
817        "- PATH health: {} duplicates, {} missing entries\n",
818        path_stats.duplicate_entries.len(),
819        path_stats.missing_entries.len()
820    ));
821    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
822    out.push_str(&format!(
823        "- Package managers found: {}\n",
824        package_managers.found.len()
825    ));
826
827    out.push_str("\nLikely causes:\n");
828    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
829        out.push_str(
830            "- 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",
831        );
832    }
833    if path_stats.duplicate_entries.is_empty()
834        && path_stats.missing_entries.is_empty()
835        && !findings.is_empty()
836    {
837        for finding in findings.iter().take(max_entries.max(4)) {
838            out.push_str(&format!("- {}\n", finding));
839        }
840    } else {
841        if !path_stats.duplicate_entries.is_empty() {
842            out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
843        }
844        if !path_stats.missing_entries.is_empty() {
845            out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
846        }
847    }
848    if found_tools.contains("node")
849        && !found_managers.contains("npm")
850        && !found_managers.contains("pnpm")
851    {
852        out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
853    }
854    if found_tools.contains("python")
855        && !found_managers.contains("pip")
856        && !found_managers.contains("uv")
857        && !found_managers.contains("pipx")
858    {
859        out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
860    }
861
862    out.push_str("\nFix plan:\n");
863    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");
864    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
865        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");
866    } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
867        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");
868    }
869    if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
870        out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
871    }
872    if found_tools.contains("node")
873        && !found_managers.contains("npm")
874        && !found_managers.contains("pnpm")
875    {
876        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");
877    }
878    if found_tools.contains("python")
879        && !found_managers.contains("pip")
880        && !found_managers.contains("uv")
881        && !found_managers.contains("pipx")
882    {
883        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");
884    }
885
886    if !path_stats.duplicate_entries.is_empty() {
887        out.push_str("\nExample duplicate PATH rows:\n");
888        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
889            out.push_str(&format!("- {}\n", entry));
890        }
891    }
892    if !path_stats.missing_entries.is_empty() {
893        out.push_str("\nExample missing PATH rows:\n");
894        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
895            out.push_str(&format!("- {}\n", entry));
896        }
897    }
898
899    out.push_str(
900        "\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.",
901    );
902    Ok(out.trim_end().to_string())
903}
904
905fn inspect_port_fix_plan(
906    issue: &str,
907    port_filter: Option<u16>,
908    max_entries: usize,
909) -> Result<String, String> {
910    let requested_port = port_filter.or_else(|| first_port_in_text(issue));
911    let listeners = collect_listening_ports().unwrap_or_default();
912    let mut matching = listeners;
913    if let Some(port) = requested_port {
914        matching.retain(|entry| entry.port == port);
915    }
916    let processes = collect_processes().unwrap_or_default();
917
918    let mut out = String::from("Host inspection: fix_plan\n\n");
919    out.push_str(&format!("- Requested issue: {}\n", issue));
920    out.push_str("- Fix-plan type: port_conflict\n");
921    if let Some(port) = requested_port {
922        out.push_str(&format!("- Requested port: {}\n", port));
923    } else {
924        out.push_str("- Requested port: not parsed from the issue text\n");
925    }
926    out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
927
928    if !matching.is_empty() {
929        out.push_str("\nCurrent listeners:\n");
930        for entry in matching.iter().take(max_entries.min(5)) {
931            let process_name = entry
932                .pid
933                .as_deref()
934                .and_then(|pid| pid.parse::<u32>().ok())
935                .and_then(|pid| {
936                    processes
937                        .iter()
938                        .find(|process| process.pid == pid)
939                        .map(|process| process.name.as_str())
940                })
941                .unwrap_or("unknown");
942            let pid = entry.pid.as_deref().unwrap_or("unknown");
943            out.push_str(&format!(
944                "- {} {} ({}) pid {} process {}\n",
945                entry.protocol, entry.local, entry.state, pid, process_name
946            ));
947        }
948    }
949
950    out.push_str("\nFix plan:\n");
951    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");
952    if !matching.is_empty() {
953        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");
954    } else {
955        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");
956    }
957    out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
958    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");
959    out.push_str(
960        "\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.",
961    );
962    Ok(out.trim_end().to_string())
963}
964
965async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
966    let config = crate::agent::config::load_config();
967    let configured_api = config
968        .api_url
969        .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
970    let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
971    let reachability = probe_http_endpoint(&models_url).await;
972    let embed_model = detect_loaded_embed_model(&configured_api).await;
973
974    let mut out = String::from("Host inspection: fix_plan\n\n");
975    out.push_str(&format!("- Requested issue: {}\n", issue));
976    out.push_str("- Fix-plan type: lm_studio\n");
977    out.push_str(&format!("- Configured API URL: {}\n", configured_api));
978    out.push_str(&format!("- Probe URL: {}\n", models_url));
979    match &reachability {
980        EndpointProbe::Reachable(status) => {
981            out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
982        }
983        EndpointProbe::Unreachable(detail) => {
984            out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
985        }
986    }
987    out.push_str(&format!(
988        "- Embedding model loaded: {}\n",
989        embed_model.as_deref().unwrap_or("none detected")
990    ));
991
992    out.push_str("\nFix plan:\n");
993    match reachability {
994        EndpointProbe::Reachable(_) => {
995            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");
996        }
997        EndpointProbe::Unreachable(_) => {
998            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");
999        }
1000    }
1001    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");
1002    out.push_str("- If chat works but semantic search does not, load the embedding model as a second resident model in LM Studio. Hematite expects a `nomic-embed` style model there.\n");
1003    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");
1004    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");
1005    if let Some(model) = embed_model {
1006        out.push_str(&format!(
1007            "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
1008            model
1009        ));
1010    }
1011    if max_entries > 0 {
1012        out.push_str(
1013            "\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.",
1014        );
1015    }
1016    Ok(out.trim_end().to_string())
1017}
1018
1019fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
1020    // Read GPU info from the hardware topic output for grounding
1021    #[cfg(target_os = "windows")]
1022    let gpu_info = {
1023        let out = Command::new("powershell")
1024            .args([
1025                "-NoProfile",
1026                "-NonInteractive",
1027                "-Command",
1028                "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
1029            ])
1030            .output()
1031            .ok()
1032            .and_then(|o| String::from_utf8(o.stdout).ok())
1033            .unwrap_or_default();
1034        out.trim().to_string()
1035    };
1036    #[cfg(not(target_os = "windows"))]
1037    let gpu_info = String::from("(GPU detection not available on this platform)");
1038
1039    let mut out = String::from("Host inspection: fix_plan\n\n");
1040    out.push_str(&format!("- Requested issue: {}\n", issue));
1041    out.push_str("- Fix-plan type: driver_install\n");
1042    if !gpu_info.is_empty() {
1043        out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
1044    }
1045    out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
1046    out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
1047    out.push_str(
1048        "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
1049    );
1050    out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
1051    out.push_str("4. Download the latest driver directly from the manufacturer:\n");
1052    out.push_str("   - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
1053    out.push_str("   - AMD: amd.com/support (use Auto-Detect tool)\n");
1054    out.push_str("   - Intel: intel.com/content/www/us/en/download-center/home.html\n");
1055    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");
1056    out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
1057    out.push_str("\nVerification:\n");
1058    out.push_str("- After reboot, run in PowerShell:\n  Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
1059    out.push_str("- The DriverVersion should match what you installed.\n");
1060    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.");
1061    Ok(out.trim_end().to_string())
1062}
1063
1064fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
1065    // Check Windows edition — Group Policy editor is not available on Home editions
1066    #[cfg(target_os = "windows")]
1067    let edition = {
1068        Command::new("powershell")
1069            .args([
1070                "-NoProfile",
1071                "-NonInteractive",
1072                "-Command",
1073                "(Get-CimInstance Win32_OperatingSystem).Caption",
1074            ])
1075            .output()
1076            .ok()
1077            .and_then(|o| String::from_utf8(o.stdout).ok())
1078            .unwrap_or_default()
1079            .trim()
1080            .to_string()
1081    };
1082    #[cfg(not(target_os = "windows"))]
1083    let edition = String::from("(Windows edition detection not available)");
1084
1085    let is_home = edition.to_lowercase().contains("home");
1086
1087    let mut out = String::from("Host inspection: fix_plan\n\n");
1088    out.push_str(&format!("- Requested issue: {}\n", issue));
1089    out.push_str("- Fix-plan type: group_policy\n");
1090    out.push_str(&format!(
1091        "- Windows edition detected: {}\n",
1092        if edition.is_empty() {
1093            "unknown".to_string()
1094        } else {
1095            edition.clone()
1096        }
1097    ));
1098
1099    if is_home {
1100        out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
1101        out.push_str("Options on Home edition:\n");
1102        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");
1103        out.push_str(
1104            "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
1105        );
1106        out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
1107    } else {
1108        out.push_str("\nFix plan — Editing Local Group Policy:\n");
1109        out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
1110        out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
1111        out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
1112        out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
1113        out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
1114        out.push_str("6. To force immediate application, run in an elevated PowerShell:\n  gpupdate /force\n");
1115    }
1116    out.push_str("\nVerification:\n");
1117    out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
1118    out.push_str(
1119        "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
1120    );
1121    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.");
1122    Ok(out.trim_end().to_string())
1123}
1124
1125fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
1126    #[cfg(target_os = "windows")]
1127    let profile_state = {
1128        Command::new("powershell")
1129            .args([
1130                "-NoProfile",
1131                "-NonInteractive",
1132                "-Command",
1133                "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
1134            ])
1135            .output()
1136            .ok()
1137            .and_then(|o| String::from_utf8(o.stdout).ok())
1138            .unwrap_or_default()
1139            .trim()
1140            .to_string()
1141    };
1142    #[cfg(not(target_os = "windows"))]
1143    let profile_state = String::new();
1144
1145    let mut out = String::from("Host inspection: fix_plan\n\n");
1146    out.push_str(&format!("- Requested issue: {}\n", issue));
1147    out.push_str("- Fix-plan type: firewall_rule\n");
1148    if !profile_state.is_empty() {
1149        out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
1150    }
1151    out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
1152    out.push_str("\nTo ALLOW inbound traffic on a port:\n");
1153    out.push_str("  New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
1154    out.push_str("\nTo BLOCK outbound traffic to an address:\n");
1155    out.push_str("  New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
1156    out.push_str("\nTo ALLOW an application through the firewall:\n");
1157    out.push_str("  New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
1158    out.push_str("\nTo REMOVE a rule you created:\n");
1159    out.push_str("  Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
1160    out.push_str("\nTo see existing custom rules:\n");
1161    out.push_str("  Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1162    out.push_str("\nVerification:\n");
1163    out.push_str("- After creating the rule, test reachability from another machine or use:\n  Test-NetConnection -ComputerName localhost -Port 8080\n");
1164    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.");
1165    Ok(out.trim_end().to_string())
1166}
1167
1168fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1169    let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1170    let ssh_dir = home.join(".ssh");
1171    let has_ssh_dir = ssh_dir.exists();
1172    let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1173    let has_rsa = ssh_dir.join("id_rsa").exists();
1174    let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1175
1176    let mut out = String::from("Host inspection: fix_plan\n\n");
1177    out.push_str(&format!("- Requested issue: {}\n", issue));
1178    out.push_str("- Fix-plan type: ssh_key\n");
1179    out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
1180    out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1181    out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1182    out.push_str(&format!(
1183        "- authorized_keys found: {}\n",
1184        has_authorized_keys
1185    ));
1186
1187    if has_ed25519 {
1188        out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1189    }
1190
1191    out.push_str("\nFix plan — Generating an SSH key pair:\n");
1192    out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1193    out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1194    out.push_str("   ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1195    out.push_str(
1196        "   - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1197    );
1198    out.push_str("   - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1199    out.push_str("3. Start the SSH agent and add your key:\n");
1200    out.push_str("   # Windows (PowerShell, run as Admin once to enable the service):\n");
1201    out.push_str("   Set-Service -Name ssh-agent -StartupType Automatic\n");
1202    out.push_str("   Start-Service ssh-agent\n");
1203    out.push_str("   # Then add the key (normal PowerShell):\n");
1204    out.push_str("   ssh-add ~/.ssh/id_ed25519\n");
1205    out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1206    out.push_str("   # Print your public key:\n");
1207    out.push_str("   cat ~/.ssh/id_ed25519.pub\n");
1208    out.push_str("   # On the target server, append it:\n");
1209    out.push_str("   echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1210    out.push_str("   chmod 600 ~/.ssh/authorized_keys\n");
1211    out.push_str("5. Test the connection:\n");
1212    out.push_str("   ssh user@server-address\n");
1213    out.push_str("\nFor GitHub/GitLab:\n");
1214    out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1215    out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1216    out.push_str("- Test: ssh -T git@github.com\n");
1217    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.");
1218    Ok(out.trim_end().to_string())
1219}
1220
1221fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1222    #[cfg(target_os = "windows")]
1223    let wsl_status = {
1224        let out = Command::new("wsl")
1225            .args(["--status"])
1226            .output()
1227            .ok()
1228            .and_then(|o| {
1229                let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1230                let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1231                Some(format!("{}{}", stdout, stderr))
1232            })
1233            .unwrap_or_default();
1234        out.trim().to_string()
1235    };
1236    #[cfg(not(target_os = "windows"))]
1237    let wsl_status = String::new();
1238
1239    let wsl_installed =
1240        !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1241
1242    let mut out = String::from("Host inspection: fix_plan\n\n");
1243    out.push_str(&format!("- Requested issue: {}\n", issue));
1244    out.push_str("- Fix-plan type: wsl_setup\n");
1245    out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1246    if !wsl_status.is_empty() {
1247        out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1248    }
1249
1250    if wsl_installed {
1251        out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1252        out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1253        out.push_str("   Available distros: wsl --list --online\n");
1254        out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1255        out.push_str("3. Create your Linux username and password when prompted.\n");
1256    } else {
1257        out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1258        out.push_str("1. Open PowerShell as Administrator.\n");
1259        out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1260        out.push_str("   wsl --install\n");
1261        out.push_str("   (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1262        out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1263        out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1264        out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1265        out.push_str("   wsl --set-default-version 2\n");
1266        out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1267        out.push_str("   wsl --install -d Debian\n");
1268        out.push_str("   wsl --list --online   # to see all available distros\n");
1269    }
1270    out.push_str("\nVerification:\n");
1271    out.push_str("- Run: wsl --list --verbose\n");
1272    out.push_str("- You should see your distro with State: Running and Version: 2\n");
1273    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.");
1274    Ok(out.trim_end().to_string())
1275}
1276
1277fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1278    let lower = issue.to_ascii_lowercase();
1279    // Extract service name hints from the issue text
1280    let service_hint = if lower.contains("ssh") {
1281        Some("sshd")
1282    } else if lower.contains("mysql") {
1283        Some("MySQL80")
1284    } else if lower.contains("postgres") || lower.contains("postgresql") {
1285        Some("postgresql")
1286    } else if lower.contains("redis") {
1287        Some("Redis")
1288    } else if lower.contains("nginx") {
1289        Some("nginx")
1290    } else if lower.contains("apache") {
1291        Some("Apache2.4")
1292    } else {
1293        None
1294    };
1295
1296    #[cfg(target_os = "windows")]
1297    let service_state = if let Some(svc) = service_hint {
1298        Command::new("powershell")
1299            .args([
1300                "-NoProfile",
1301                "-NonInteractive",
1302                "-Command",
1303                &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1304            ])
1305            .output()
1306            .ok()
1307            .and_then(|o| String::from_utf8(o.stdout).ok())
1308            .unwrap_or_default()
1309            .trim()
1310            .to_string()
1311    } else {
1312        String::new()
1313    };
1314    #[cfg(not(target_os = "windows"))]
1315    let service_state = String::new();
1316
1317    let mut out = String::from("Host inspection: fix_plan\n\n");
1318    out.push_str(&format!("- Requested issue: {}\n", issue));
1319    out.push_str("- Fix-plan type: service_config\n");
1320    if let Some(svc) = service_hint {
1321        out.push_str(&format!("- Service detected in request: {}\n", svc));
1322    }
1323    if !service_state.is_empty() {
1324        out.push_str(&format!("- Current state: {}\n", service_state));
1325    }
1326
1327    out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1328    out.push_str("\nStart a service:\n");
1329    out.push_str("  Start-Service -Name \"ServiceName\"\n");
1330    out.push_str("\nStop a service:\n");
1331    out.push_str("  Stop-Service -Name \"ServiceName\"\n");
1332    out.push_str("\nRestart a service:\n");
1333    out.push_str("  Restart-Service -Name \"ServiceName\"\n");
1334    out.push_str("\nEnable a service to start automatically:\n");
1335    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1336    out.push_str("\nDisable a service (stops it from auto-starting):\n");
1337    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1338    out.push_str("\nFind the exact service name:\n");
1339    out.push_str("  Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1340    out.push_str("\nVerification:\n");
1341    out.push_str("  Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1342    if let Some(svc) = service_hint {
1343        out.push_str(&format!(
1344            "\nFor your detected service ({}):\n  Get-Service -Name '{}'\n",
1345            svc, svc
1346        ));
1347    }
1348    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.");
1349    Ok(out.trim_end().to_string())
1350}
1351
1352fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1353    #[cfg(target_os = "windows")]
1354    let activation_status = {
1355        Command::new("powershell")
1356            .args([
1357                "-NoProfile",
1358                "-NonInteractive",
1359                "-Command",
1360                "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 + ')' })\" }",
1361            ])
1362            .output()
1363            .ok()
1364            .and_then(|o| String::from_utf8(o.stdout).ok())
1365            .unwrap_or_default()
1366            .trim()
1367            .to_string()
1368    };
1369    #[cfg(not(target_os = "windows"))]
1370    let activation_status = String::new();
1371
1372    let is_licensed = activation_status.to_lowercase().contains("licensed")
1373        && !activation_status.to_lowercase().contains("not licensed");
1374
1375    let mut out = String::from("Host inspection: fix_plan\n\n");
1376    out.push_str(&format!("- Requested issue: {}\n", issue));
1377    out.push_str("- Fix-plan type: windows_activation\n");
1378    if !activation_status.is_empty() {
1379        out.push_str(&format!(
1380            "- Current activation state:\n{}\n",
1381            activation_status
1382        ));
1383    }
1384
1385    if is_licensed {
1386        out.push_str(
1387            "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1388        );
1389        out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1390        out.push_str("   (Forces an online activation attempt)\n");
1391        out.push_str("2. Check activation details: slmgr /dli\n");
1392    } else {
1393        out.push_str("\nFix plan — Activating Windows:\n");
1394        out.push_str("1. Check your current status first:\n");
1395        out.push_str("   slmgr /dli   (basic info)\n");
1396        out.push_str("   slmgr /dlv   (detailed — shows remaining rearms, grace period)\n");
1397        out.push_str("\n2. If you have a retail product key:\n");
1398        out.push_str("   slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX   (install key)\n");
1399        out.push_str("   slmgr /ato                                   (activate online)\n");
1400        out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1401        out.push_str("   - Go to Settings → System → Activation\n");
1402        out.push_str("   - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1403        out.push_str("   - Sign in with the Microsoft account that holds the license\n");
1404        out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1405        out.push_str("   - Contact your IT department for the KMS server address\n");
1406        out.push_str("   - Set KMS host: slmgr /skms kms.yourorg.com\n");
1407        out.push_str("   - Activate:    slmgr /ato\n");
1408    }
1409    out.push_str("\nVerification:\n");
1410    out.push_str("  slmgr /dli   — should show 'License Status: Licensed'\n");
1411    out.push_str("  Or: Settings → System → Activation → 'Windows is activated'\n");
1412    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.");
1413    Ok(out.trim_end().to_string())
1414}
1415
1416fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1417    let mut out = String::from("Host inspection: fix_plan\n\n");
1418    out.push_str(&format!("- Requested issue: {}\n", issue));
1419    out.push_str("- Fix-plan type: registry_edit\n");
1420    out.push_str(
1421        "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1422    );
1423    out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1424    out.push_str("\n1. Back up before you touch anything:\n");
1425    out.push_str("   # Export the key you're about to change (PowerShell):\n");
1426    out.push_str("   reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1427    out.push_str("   # Or export the whole registry (takes a while):\n");
1428    out.push_str("   reg export HKLM C:\\backup\\HKLM_full.reg\n");
1429    out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1430    out.push_str("   Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1431    out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1432    out.push_str(
1433        "   Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1434    );
1435    out.push_str("\n4. Create a new key:\n");
1436    out.push_str("   New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1437    out.push_str("\n5. Delete a value:\n");
1438    out.push_str("   Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1439    out.push_str("\n6. Restore from backup if something breaks:\n");
1440    out.push_str("   reg import C:\\backup\\MyKey_backup.reg\n");
1441    out.push_str("\nCommon registry hives:\n");
1442    out.push_str("  HKLM = HKEY_LOCAL_MACHINE  (machine-wide, requires Admin)\n");
1443    out.push_str("  HKCU = HKEY_CURRENT_USER   (current user, no elevation needed)\n");
1444    out.push_str("  HKCR = HKEY_CLASSES_ROOT    (file associations)\n");
1445    out.push_str("\nVerification:\n");
1446    out.push_str("  Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1447    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.");
1448    Ok(out.trim_end().to_string())
1449}
1450
1451fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1452    let mut out = String::from("Host inspection: fix_plan\n\n");
1453    out.push_str(&format!("- Requested issue: {}\n", issue));
1454    out.push_str("- Fix-plan type: scheduled_task_create\n");
1455    out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1456    out.push_str("\nExample: Run a script at 9 AM every day\n");
1457    out.push_str("  $action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1458    out.push_str("  $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1459    out.push_str("  Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1460    out.push_str("\nExample: Run at Windows startup\n");
1461    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1462    out.push_str("  Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1463    out.push_str("\nExample: Run at user logon\n");
1464    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1465    out.push_str(
1466        "  Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1467    );
1468    out.push_str("\nExample: Run every 30 minutes\n");
1469    out.push_str("  $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1470    out.push_str("\nView all tasks:\n");
1471    out.push_str("  Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1472    out.push_str("\nDelete a task:\n");
1473    out.push_str("  Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1474    out.push_str("\nRun a task immediately:\n");
1475    out.push_str("  Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1476    out.push_str("\nVerification:\n");
1477    out.push_str("  Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1478    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.");
1479    Ok(out.trim_end().to_string())
1480}
1481
1482fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1483    #[cfg(target_os = "windows")]
1484    let disk_info = {
1485        Command::new("powershell")
1486            .args([
1487                "-NoProfile",
1488                "-NonInteractive",
1489                "-Command",
1490                "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\" }",
1491            ])
1492            .output()
1493            .ok()
1494            .and_then(|o| String::from_utf8(o.stdout).ok())
1495            .unwrap_or_default()
1496            .trim()
1497            .to_string()
1498    };
1499    #[cfg(not(target_os = "windows"))]
1500    let disk_info = String::new();
1501
1502    let mut out = String::from("Host inspection: fix_plan\n\n");
1503    out.push_str(&format!("- Requested issue: {}\n", issue));
1504    out.push_str("- Fix-plan type: disk_cleanup\n");
1505    if !disk_info.is_empty() {
1506        out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1507    }
1508    out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1509    out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1510    out.push_str("   cleanmgr /sageset:1    (configure what to clean)\n");
1511    out.push_str("   cleanmgr /sagerun:1    (run the cleanup)\n");
1512    out.push_str("   Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1513    out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1514    out.push_str("   Stop-Service wuauserv\n");
1515    out.push_str("   Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1516    out.push_str("   Start-Service wuauserv\n");
1517    out.push_str("\n3. Clear Windows Temp folder:\n");
1518    out.push_str("   Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1519    out.push_str(
1520        "   Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1521    );
1522    out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1523    out.push_str("   - Rust build artifacts: cargo clean  (inside each project)\n");
1524    out.push_str("   - npm cache:  npm cache clean --force\n");
1525    out.push_str("   - pip cache:  pip cache purge\n");
1526    out.push_str(
1527        "   - Docker:     docker system prune -a  (removes all unused images/containers)\n",
1528    );
1529    out.push_str("   - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force  (will redownload on next build)\n");
1530    out.push_str("\n5. Check for large files:\n");
1531    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");
1532    out.push_str("\nVerification:\n");
1533    out.push_str(
1534        "  Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1535    );
1536    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.");
1537    Ok(out.trim_end().to_string())
1538}
1539
1540fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1541    let mut out = String::from("Host inspection: fix_plan\n\n");
1542    out.push_str(&format!("- Requested issue: {}\n", issue));
1543    out.push_str("- Fix-plan type: generic\n");
1544    out.push_str(
1545        "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1546         Structured lanes available:\n\
1547         - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1548         - Port conflict (address already in use, what owns port)\n\
1549         - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1550         - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1551         - Group Policy (gpedit, local policy, administrative template)\n\
1552         - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1553         - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1554         - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1555         - Service config (start/stop/restart/enable/disable a service)\n\
1556         - Windows activation (product key, not activated, kms)\n\
1557         - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1558         - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1559         - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1560         - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1561    );
1562    Ok(out.trim_end().to_string())
1563}
1564
1565fn inspect_resource_load() -> Result<String, String> {
1566    #[cfg(target_os = "windows")]
1567    {
1568        let output = Command::new("powershell")
1569            .args([
1570                "-NoProfile",
1571                "-Command",
1572                "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1573            ])
1574            .output()
1575            .map_err(|e| format!("Failed to run powershell: {e}"))?;
1576
1577        let text = String::from_utf8_lossy(&output.stdout);
1578        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1579
1580        let cpu_load = lines
1581            .next()
1582            .and_then(|l| l.parse::<u32>().ok())
1583            .unwrap_or(0);
1584        let mem_json = lines.collect::<Vec<_>>().join("");
1585        let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1586
1587        let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1588        let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1589        let used_kb = total_kb.saturating_sub(free_kb);
1590        let mem_percent = if total_kb > 0 {
1591            (used_kb * 100) / total_kb
1592        } else {
1593            0
1594        };
1595
1596        let mut out = String::from("Host inspection: resource_load\n\n");
1597        out.push_str("**System Performance Summary:**\n");
1598        out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1599        out.push_str(&format!(
1600            "- Memory Usage: {} / {} ({}%)\n",
1601            human_bytes(used_kb * 1024),
1602            human_bytes(total_kb * 1024),
1603            mem_percent
1604        ));
1605
1606        if cpu_load > 85 {
1607            out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1608        }
1609        if mem_percent > 90 {
1610            out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1611        }
1612
1613        Ok(out)
1614    }
1615    #[cfg(not(target_os = "windows"))]
1616    {
1617        Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1618    }
1619}
1620
1621#[derive(Debug)]
1622enum EndpointProbe {
1623    Reachable(u16),
1624    Unreachable(String),
1625}
1626
1627async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1628    let client = match reqwest::Client::builder()
1629        .timeout(std::time::Duration::from_secs(3))
1630        .build()
1631    {
1632        Ok(client) => client,
1633        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1634    };
1635
1636    match client.get(url).send().await {
1637        Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1638        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1639    }
1640}
1641
1642async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1643    let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1644    let url = format!("{}/api/v0/models", base);
1645    let client = reqwest::Client::builder()
1646        .timeout(std::time::Duration::from_secs(3))
1647        .build()
1648        .ok()?;
1649
1650    #[derive(serde::Deserialize)]
1651    struct ModelList {
1652        data: Vec<ModelEntry>,
1653    }
1654    #[derive(serde::Deserialize)]
1655    struct ModelEntry {
1656        id: String,
1657        #[serde(rename = "type", default)]
1658        model_type: String,
1659        #[serde(default)]
1660        state: String,
1661    }
1662
1663    let response = client.get(url).send().await.ok()?;
1664    let models = response.json::<ModelList>().await.ok()?;
1665    models
1666        .data
1667        .into_iter()
1668        .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1669        .map(|model| model.id)
1670}
1671
1672fn first_port_in_text(text: &str) -> Option<u16> {
1673    text.split(|c: char| !c.is_ascii_digit())
1674        .find(|fragment| !fragment.is_empty())
1675        .and_then(|fragment| fragment.parse::<u16>().ok())
1676}
1677
1678fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1679    let mut processes = collect_processes()?;
1680    if let Some(filter) = name_filter.as_deref() {
1681        let lowered = filter.to_ascii_lowercase();
1682        processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1683    }
1684    processes.sort_by(|a, b| {
1685        let a_cpu = a.cpu_percent.unwrap_or(0.0);
1686        let b_cpu = b.cpu_percent.unwrap_or(0.0);
1687        b_cpu
1688            .partial_cmp(&a_cpu)
1689            .unwrap_or(std::cmp::Ordering::Equal)
1690            .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1691            .then_with(|| a.name.cmp(&b.name))
1692            .then_with(|| a.pid.cmp(&b.pid))
1693    });
1694
1695    let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1696
1697    let mut out = String::from("Host inspection: processes\n\n");
1698    if let Some(filter) = name_filter.as_deref() {
1699        out.push_str(&format!("- Filter name: {}\n", filter));
1700    }
1701    out.push_str(&format!("- Processes found: {}\n", processes.len()));
1702    out.push_str(&format!(
1703        "- Total reported working set: {}\n",
1704        human_bytes(total_memory)
1705    ));
1706
1707    if processes.is_empty() {
1708        out.push_str("\nNo running processes matched.");
1709        return Ok(out);
1710    }
1711
1712    out.push_str("\nTop processes by resource usage:\n");
1713    for entry in processes.iter().take(max_entries) {
1714        let cpu_str = entry
1715            .cpu_percent
1716            .map(|p| format!(" [CPU: {:.1}%]", p))
1717            .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1718            .unwrap_or_default();
1719        let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1720            format!(" [I/O R:{}/W:{}]", r, w)
1721        } else {
1722            " [I/O unknown]".to_string()
1723        };
1724        out.push_str(&format!(
1725            "- {} (pid {}) - {}{}{}{}\n",
1726            entry.name,
1727            entry.pid,
1728            human_bytes(entry.memory_bytes),
1729            cpu_str,
1730            io_str,
1731            entry
1732                .detail
1733                .as_deref()
1734                .map(|detail| format!(" [{}]", detail))
1735                .unwrap_or_default()
1736        ));
1737    }
1738    if processes.len() > max_entries {
1739        out.push_str(&format!(
1740            "- ... {} more processes omitted\n",
1741            processes.len() - max_entries
1742        ));
1743    }
1744
1745    Ok(out.trim_end().to_string())
1746}
1747
1748fn inspect_network(max_entries: usize) -> Result<String, String> {
1749    let adapters = collect_network_adapters()?;
1750    let active_count = adapters
1751        .iter()
1752        .filter(|adapter| adapter.is_active())
1753        .count();
1754    let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1755
1756    let mut out = String::from("Host inspection: network\n\n");
1757    out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1758    out.push_str(&format!("- Active adapters: {}\n", active_count));
1759    out.push_str(&format!(
1760        "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1761        exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1762    ));
1763
1764    if adapters.is_empty() {
1765        out.push_str("\nNo adapter details were detected.");
1766        return Ok(out);
1767    }
1768
1769    out.push_str("\nAdapter summary:\n");
1770    for adapter in adapters.iter().take(max_entries) {
1771        let status = if adapter.is_active() {
1772            "active"
1773        } else if adapter.disconnected {
1774            "disconnected"
1775        } else {
1776            "idle"
1777        };
1778        let mut details = vec![status.to_string()];
1779        if !adapter.ipv4.is_empty() {
1780            details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1781        }
1782        if !adapter.ipv6.is_empty() {
1783            details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1784        }
1785        if !adapter.gateways.is_empty() {
1786            details.push(format!("gateway {}", adapter.gateways.join(", ")));
1787        }
1788        if !adapter.dns_servers.is_empty() {
1789            details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1790        }
1791        out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1792    }
1793    if adapters.len() > max_entries {
1794        out.push_str(&format!(
1795            "- ... {} more adapters omitted\n",
1796            adapters.len() - max_entries
1797        ));
1798    }
1799
1800    Ok(out.trim_end().to_string())
1801}
1802
1803fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1804    let mut out = String::from("Host inspection: lan_discovery\n\n");
1805
1806    #[cfg(target_os = "windows")]
1807    {
1808        let n = max_entries.clamp(5, 20);
1809        let adapters = collect_network_adapters()?;
1810        let services = collect_services().unwrap_or_default();
1811        let active_adapters: Vec<&NetworkAdapter> = adapters
1812            .iter()
1813            .filter(|adapter| adapter.is_active())
1814            .collect();
1815        let gateways: Vec<String> = active_adapters
1816            .iter()
1817            .flat_map(|adapter| adapter.gateways.clone())
1818            .collect::<HashSet<_>>()
1819            .into_iter()
1820            .collect();
1821
1822        let neighbor_script = r#"
1823$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1824    Where-Object {
1825        $_.IPAddress -notlike '127.*' -and
1826        $_.IPAddress -notlike '169.254*' -and
1827        $_.State -notin @('Unreachable','Invalid')
1828    } |
1829    Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1830$neighbors | ConvertTo-Json -Compress
1831"#;
1832        let neighbor_text = Command::new("powershell")
1833            .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1834            .output()
1835            .ok()
1836            .and_then(|o| String::from_utf8(o.stdout).ok())
1837            .unwrap_or_default();
1838        let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1839            .into_iter()
1840            .take(n)
1841            .collect();
1842
1843        let listener_script = r#"
1844Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1845    Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1846    Select-Object LocalAddress, LocalPort, OwningProcess |
1847    ForEach-Object {
1848        $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1849        "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1850    }
1851"#;
1852        let listener_text = Command::new("powershell")
1853            .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1854            .output()
1855            .ok()
1856            .and_then(|o| String::from_utf8(o.stdout).ok())
1857            .unwrap_or_default();
1858        let listeners: Vec<(String, u16, String, String)> = listener_text
1859            .lines()
1860            .filter_map(|line| {
1861                let parts: Vec<&str> = line.trim().split('|').collect();
1862                if parts.len() < 4 {
1863                    return None;
1864                }
1865                Some((
1866                    parts[0].to_string(),
1867                    parts[1].parse::<u16>().ok()?,
1868                    parts[2].to_string(),
1869                    parts[3].to_string(),
1870                ))
1871            })
1872            .take(n)
1873            .collect();
1874
1875        let smb_mapping_script = r#"
1876Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1877    ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1878"#;
1879        let smb_mappings: Vec<String> = Command::new("powershell")
1880            .args([
1881                "-NoProfile",
1882                "-NonInteractive",
1883                "-Command",
1884                smb_mapping_script,
1885            ])
1886            .output()
1887            .ok()
1888            .and_then(|o| String::from_utf8(o.stdout).ok())
1889            .unwrap_or_default()
1890            .lines()
1891            .take(n)
1892            .map(|line| line.trim().to_string())
1893            .filter(|line| !line.is_empty())
1894            .collect();
1895
1896        let smb_connections_script = r#"
1897Get-SmbConnection -ErrorAction SilentlyContinue |
1898    Select-Object ServerName, ShareName, NumOpens |
1899    ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1900"#;
1901        let smb_connections: Vec<String> = Command::new("powershell")
1902            .args([
1903                "-NoProfile",
1904                "-NonInteractive",
1905                "-Command",
1906                smb_connections_script,
1907            ])
1908            .output()
1909            .ok()
1910            .and_then(|o| String::from_utf8(o.stdout).ok())
1911            .unwrap_or_default()
1912            .lines()
1913            .take(n)
1914            .map(|line| line.trim().to_string())
1915            .filter(|line| !line.is_empty())
1916            .collect();
1917
1918        let discovery_service_names = [
1919            "FDResPub",
1920            "fdPHost",
1921            "SSDPSRV",
1922            "upnphost",
1923            "LanmanServer",
1924            "LanmanWorkstation",
1925            "lmhosts",
1926        ];
1927        let discovery_services: Vec<&ServiceEntry> = services
1928            .iter()
1929            .filter(|entry| {
1930                discovery_service_names
1931                    .iter()
1932                    .any(|name| entry.name.eq_ignore_ascii_case(name))
1933            })
1934            .collect();
1935
1936        let mut findings = Vec::new();
1937        if active_adapters.is_empty() {
1938            findings.push(AuditFinding {
1939                finding: "No active LAN adapters were detected.".to_string(),
1940                impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
1941                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(),
1942            });
1943        }
1944
1945        let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
1946            .iter()
1947            .copied()
1948            .filter(|entry| {
1949                !entry.status.eq_ignore_ascii_case("running")
1950                    && !entry.status.eq_ignore_ascii_case("active")
1951            })
1952            .collect();
1953        if !stopped_discovery_services.is_empty() {
1954            let names = stopped_discovery_services
1955                .iter()
1956                .map(|entry| entry.name.as_str())
1957                .collect::<Vec<_>>()
1958                .join(", ");
1959            findings.push(AuditFinding {
1960                finding: format!("Discovery-related services are not running: {names}"),
1961                impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
1962                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(),
1963            });
1964        }
1965
1966        if listeners.is_empty() {
1967            findings.push(AuditFinding {
1968                finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
1969                impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
1970                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(),
1971            });
1972        }
1973
1974        if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
1975            findings.push(AuditFinding {
1976                finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
1977                impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
1978                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(),
1979            });
1980        }
1981
1982        out.push_str("=== Findings ===\n");
1983        if findings.is_empty() {
1984            out.push_str(
1985                "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
1986            );
1987            out.push_str("  Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
1988            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");
1989        } else {
1990            for finding in &findings {
1991                out.push_str(&format!("- Finding: {}\n", finding.finding));
1992                out.push_str(&format!("  Impact: {}\n", finding.impact));
1993                out.push_str(&format!("  Fix: {}\n", finding.fix));
1994            }
1995        }
1996
1997        out.push_str("\n=== Active adapter and gateway summary ===\n");
1998        if active_adapters.is_empty() {
1999            out.push_str("- No active adapters detected.\n");
2000        } else {
2001            for adapter in active_adapters.iter().take(n) {
2002                let ipv4 = if adapter.ipv4.is_empty() {
2003                    "no IPv4".to_string()
2004                } else {
2005                    adapter.ipv4.join(", ")
2006                };
2007                let gateway = if adapter.gateways.is_empty() {
2008                    "no gateway".to_string()
2009                } else {
2010                    adapter.gateways.join(", ")
2011                };
2012                out.push_str(&format!(
2013                    "- {} | IPv4: {} | Gateway: {}\n",
2014                    adapter.name, ipv4, gateway
2015                ));
2016            }
2017        }
2018
2019        out.push_str("\n=== Neighborhood evidence ===\n");
2020        out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
2021        out.push_str(&format!(
2022            "- Neighbor entries observed: {}\n",
2023            neighbors.len()
2024        ));
2025        if neighbors.is_empty() {
2026            out.push_str("- No ARP/neighbor evidence retrieved.\n");
2027        } else {
2028            for (ip, mac, state, iface) in neighbors.iter().take(n) {
2029                out.push_str(&format!(
2030                    "- {} on {} | MAC: {} | State: {}\n",
2031                    ip, iface, mac, state
2032                ));
2033            }
2034        }
2035
2036        out.push_str("\n=== Discovery services ===\n");
2037        if discovery_services.is_empty() {
2038            out.push_str("- Discovery service status unavailable.\n");
2039        } else {
2040            for entry in discovery_services.iter().take(n) {
2041                let startup = entry.startup.as_deref().unwrap_or("unknown");
2042                out.push_str(&format!(
2043                    "- {} | Status: {} | Startup: {}\n",
2044                    entry.name, entry.status, startup
2045                ));
2046            }
2047        }
2048
2049        out.push_str("\n=== Discovery listener surface ===\n");
2050        if listeners.is_empty() {
2051            out.push_str("- No discovery-oriented UDP listeners detected.\n");
2052        } else {
2053            for (addr, port, pid, proc_name) in listeners.iter().take(n) {
2054                let label = match *port {
2055                    137 => "NetBIOS Name Service",
2056                    138 => "NetBIOS Datagram",
2057                    1900 => "SSDP/UPnP",
2058                    5353 => "mDNS",
2059                    5355 => "LLMNR",
2060                    _ => "Discovery",
2061                };
2062                let proc_label = if proc_name.is_empty() {
2063                    "unknown".to_string()
2064                } else {
2065                    proc_name.clone()
2066                };
2067                out.push_str(&format!(
2068                    "- {}:{} | {} | PID {} ({})\n",
2069                    addr, port, label, pid, proc_label
2070                ));
2071            }
2072        }
2073
2074        out.push_str("\n=== SMB and neighborhood visibility ===\n");
2075        if smb_mappings.is_empty() && smb_connections.is_empty() {
2076            out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
2077        } else {
2078            if !smb_mappings.is_empty() {
2079                out.push_str("- Mapped drives:\n");
2080                for mapping in smb_mappings.iter().take(n) {
2081                    let parts: Vec<&str> = mapping.split('|').collect();
2082                    if parts.len() >= 2 {
2083                        out.push_str(&format!("  - {} -> {}\n", parts[0], parts[1]));
2084                    }
2085                }
2086            }
2087            if !smb_connections.is_empty() {
2088                out.push_str("- Active SMB connections:\n");
2089                for connection in smb_connections.iter().take(n) {
2090                    let parts: Vec<&str> = connection.split('|').collect();
2091                    if parts.len() >= 3 {
2092                        out.push_str(&format!(
2093                            "  - {}\\{} | Opens: {}\n",
2094                            parts[0], parts[1], parts[2]
2095                        ));
2096                    }
2097                }
2098            }
2099        }
2100    }
2101
2102    #[cfg(not(target_os = "windows"))]
2103    {
2104        let n = max_entries.clamp(5, 20);
2105        let adapters = collect_network_adapters()?;
2106        let arp_output = Command::new("ip")
2107            .args(["neigh"])
2108            .output()
2109            .ok()
2110            .and_then(|o| String::from_utf8(o.stdout).ok())
2111            .unwrap_or_default();
2112        let neighbors: Vec<&str> = arp_output
2113            .lines()
2114            .filter(|line| !line.trim().is_empty())
2115            .take(n)
2116            .collect();
2117
2118        out.push_str("=== Findings ===\n");
2119        if adapters.iter().any(|adapter| adapter.is_active()) {
2120            out.push_str(
2121                "- Finding: LAN discovery support is partially available on this platform.\n",
2122            );
2123            out.push_str("  Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
2124            out.push_str("  Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
2125        } else {
2126            out.push_str("- Finding: No active LAN adapters were detected.\n");
2127            out.push_str(
2128                "  Impact: Neighborhood discovery cannot work without an active interface.\n",
2129            );
2130            out.push_str("  Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
2131        }
2132
2133        out.push_str("\n=== Active adapter and gateway summary ===\n");
2134        if adapters.is_empty() {
2135            out.push_str("- No adapters detected.\n");
2136        } else {
2137            for adapter in adapters.iter().take(n) {
2138                let ipv4 = if adapter.ipv4.is_empty() {
2139                    "no IPv4".to_string()
2140                } else {
2141                    adapter.ipv4.join(", ")
2142                };
2143                let gateway = if adapter.gateways.is_empty() {
2144                    "no gateway".to_string()
2145                } else {
2146                    adapter.gateways.join(", ")
2147                };
2148                out.push_str(&format!(
2149                    "- {} | IPv4: {} | Gateway: {}\n",
2150                    adapter.name, ipv4, gateway
2151                ));
2152            }
2153        }
2154
2155        out.push_str("\n=== Neighborhood evidence ===\n");
2156        if neighbors.is_empty() {
2157            out.push_str("- No neighbor entries detected.\n");
2158        } else {
2159            for line in neighbors {
2160                out.push_str(&format!("- {}\n", line.trim()));
2161            }
2162        }
2163    }
2164
2165    Ok(out.trim_end().to_string())
2166}
2167
2168fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2169    let mut services = collect_services()?;
2170    if let Some(filter) = name_filter.as_deref() {
2171        let lowered = filter.to_ascii_lowercase();
2172        services.retain(|entry| {
2173            entry.name.to_ascii_lowercase().contains(&lowered)
2174                || entry
2175                    .display_name
2176                    .as_deref()
2177                    .map(|d| d.to_ascii_lowercase().contains(&lowered))
2178                    .unwrap_or(false)
2179        });
2180    }
2181
2182    services.sort_by(|a, b| {
2183        let a_running =
2184            a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2185        let b_running =
2186            b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2187        b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2188    });
2189
2190    let running = services
2191        .iter()
2192        .filter(|entry| {
2193            entry.status.eq_ignore_ascii_case("running")
2194                || entry.status.eq_ignore_ascii_case("active")
2195        })
2196        .count();
2197    let failed = services
2198        .iter()
2199        .filter(|entry| {
2200            entry.status.eq_ignore_ascii_case("failed")
2201                || entry.status.eq_ignore_ascii_case("error")
2202                || entry.status.eq_ignore_ascii_case("stopped")
2203        })
2204        .count();
2205
2206    let mut out = String::from("Host inspection: services\n\n");
2207    if let Some(filter) = name_filter.as_deref() {
2208        out.push_str(&format!("- Filter name: {}\n", filter));
2209    }
2210    out.push_str(&format!("- Services found: {}\n", services.len()));
2211    out.push_str(&format!("- Running/active: {}\n", running));
2212    out.push_str(&format!("- Failed/stopped: {}\n", failed));
2213
2214    if services.is_empty() {
2215        out.push_str("\nNo services matched.");
2216        return Ok(out);
2217    }
2218
2219    // Split into running and stopped sections so both are always visible.
2220    let per_section = (max_entries / 2).max(5);
2221
2222    let running_services: Vec<_> = services
2223        .iter()
2224        .filter(|e| {
2225            e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2226        })
2227        .collect();
2228    let stopped_services: Vec<_> = services
2229        .iter()
2230        .filter(|e| {
2231            e.status.eq_ignore_ascii_case("stopped")
2232                || e.status.eq_ignore_ascii_case("failed")
2233                || e.status.eq_ignore_ascii_case("error")
2234        })
2235        .collect();
2236
2237    let fmt_entry = |entry: &&ServiceEntry| {
2238        let startup = entry
2239            .startup
2240            .as_deref()
2241            .map(|v| format!(" | startup {}", v))
2242            .unwrap_or_default();
2243        let logon = entry
2244            .start_name
2245            .as_deref()
2246            .map(|v| format!(" | LogOn: {}", v))
2247            .unwrap_or_default();
2248        let display = entry
2249            .display_name
2250            .as_deref()
2251            .filter(|v| *v != &entry.name)
2252            .map(|v| format!(" [{}]", v))
2253            .unwrap_or_default();
2254        format!(
2255            "- {}{} - {}{}{}\n",
2256            entry.name, display, entry.status, startup, logon
2257        )
2258    };
2259
2260    out.push_str(&format!(
2261        "\nRunning services ({} total, showing up to {}):\n",
2262        running_services.len(),
2263        per_section
2264    ));
2265    for entry in running_services.iter().take(per_section) {
2266        out.push_str(&fmt_entry(entry));
2267    }
2268    if running_services.len() > per_section {
2269        out.push_str(&format!(
2270            "- ... {} more running services omitted\n",
2271            running_services.len() - per_section
2272        ));
2273    }
2274
2275    out.push_str(&format!(
2276        "\nStopped/failed services ({} total, showing up to {}):\n",
2277        stopped_services.len(),
2278        per_section
2279    ));
2280    for entry in stopped_services.iter().take(per_section) {
2281        out.push_str(&fmt_entry(entry));
2282    }
2283    if stopped_services.len() > per_section {
2284        out.push_str(&format!(
2285            "- ... {} more stopped services omitted\n",
2286            stopped_services.len() - per_section
2287        ));
2288    }
2289
2290    Ok(out.trim_end().to_string())
2291}
2292
2293async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2294    inspect_directory("Disk", path, max_entries).await
2295}
2296
2297fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2298    let mut listeners = collect_listening_ports()?;
2299    if let Some(port) = port_filter {
2300        listeners.retain(|entry| entry.port == port);
2301    }
2302    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2303
2304    let mut out = String::from("Host inspection: ports\n\n");
2305    if let Some(port) = port_filter {
2306        out.push_str(&format!("- Filter port: {}\n", port));
2307    }
2308    out.push_str(&format!(
2309        "- Listening endpoints found: {}\n",
2310        listeners.len()
2311    ));
2312
2313    if listeners.is_empty() {
2314        out.push_str("\nNo listening endpoints matched.");
2315        return Ok(out);
2316    }
2317
2318    out.push_str("\nListening endpoints:\n");
2319    for entry in listeners.iter().take(max_entries) {
2320        let pid_str = entry
2321            .pid
2322            .as_deref()
2323            .map(|p| format!(" pid {}", p))
2324            .unwrap_or_default();
2325        let name_str = entry
2326            .process_name
2327            .as_deref()
2328            .map(|n| format!(" [{}]", n))
2329            .unwrap_or_default();
2330        out.push_str(&format!(
2331            "- {} {} ({}){}{}\n",
2332            entry.protocol, entry.local, entry.state, pid_str, name_str
2333        ));
2334    }
2335    if listeners.len() > max_entries {
2336        out.push_str(&format!(
2337            "- ... {} more listening endpoints omitted\n",
2338            listeners.len() - max_entries
2339        ));
2340    }
2341
2342    Ok(out.trim_end().to_string())
2343}
2344
2345fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2346    if !path.exists() {
2347        return Err(format!("Path does not exist: {}", path.display()));
2348    }
2349    if !path.is_dir() {
2350        return Err(format!("Path is not a directory: {}", path.display()));
2351    }
2352
2353    let markers = collect_project_markers(&path);
2354    let hematite_state = collect_hematite_state(&path);
2355    let git_state = inspect_git_state(&path);
2356    let release_state = inspect_release_artifacts(&path);
2357
2358    let mut out = String::from("Host inspection: repo_doctor\n\n");
2359    out.push_str(&format!("- Path: {}\n", path.display()));
2360    out.push_str(&format!(
2361        "- Workspace mode: {}\n",
2362        workspace_mode_for_path(&path)
2363    ));
2364
2365    if markers.is_empty() {
2366        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");
2367    } else {
2368        out.push_str("- Project markers:\n");
2369        for marker in markers.iter().take(max_entries) {
2370            out.push_str(&format!("  - {}\n", marker));
2371        }
2372    }
2373
2374    match git_state {
2375        Some(git) => {
2376            out.push_str(&format!("- Git root: {}\n", git.root.display()));
2377            out.push_str(&format!("- Git branch: {}\n", git.branch));
2378            out.push_str(&format!("- Git status: {}\n", git.status_label()));
2379        }
2380        None => out.push_str("- Git: not inside a detected work tree\n"),
2381    }
2382
2383    out.push_str(&format!(
2384        "- Hematite docs/imports/reports: {}/{}/{}\n",
2385        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2386    ));
2387    if hematite_state.workspace_profile {
2388        out.push_str("- Workspace profile: present\n");
2389    } else {
2390        out.push_str("- Workspace profile: absent\n");
2391    }
2392
2393    if let Some(release) = release_state {
2394        out.push_str(&format!("- Cargo version: {}\n", release.version));
2395        out.push_str(&format!(
2396            "- Windows artifacts for current version: {}/{}/{}\n",
2397            bool_label(release.portable_dir),
2398            bool_label(release.portable_zip),
2399            bool_label(release.setup_exe)
2400        ));
2401    }
2402
2403    Ok(out.trim_end().to_string())
2404}
2405
2406async fn inspect_known_directory(
2407    label: &str,
2408    path: Option<PathBuf>,
2409    max_entries: usize,
2410) -> Result<String, String> {
2411    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2412    inspect_directory(label, path, max_entries).await
2413}
2414
2415async fn inspect_directory(
2416    label: &str,
2417    path: PathBuf,
2418    max_entries: usize,
2419) -> Result<String, String> {
2420    let label = label.to_string();
2421    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2422        .await
2423        .map_err(|e| format!("inspect_host task failed: {e}"))?
2424}
2425
2426fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2427    if !path.exists() {
2428        return Err(format!("Path does not exist: {}", path.display()));
2429    }
2430    if !path.is_dir() {
2431        return Err(format!("Path is not a directory: {}", path.display()));
2432    }
2433
2434    let mut top_level_entries = Vec::new();
2435    for entry in fs::read_dir(path)
2436        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2437    {
2438        match entry {
2439            Ok(entry) => top_level_entries.push(entry),
2440            Err(_) => continue,
2441        }
2442    }
2443    top_level_entries.sort_by_key(|entry| entry.file_name());
2444
2445    let top_level_count = top_level_entries.len();
2446    let mut sample_names = Vec::new();
2447    let mut largest_entries = Vec::new();
2448    let mut aggregate = PathAggregate::default();
2449    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2450
2451    for entry in top_level_entries {
2452        let name = entry.file_name().to_string_lossy().to_string();
2453        if sample_names.len() < max_entries {
2454            sample_names.push(name.clone());
2455        }
2456        let kind = match entry.file_type() {
2457            Ok(ft) if ft.is_dir() => "dir",
2458            Ok(ft) if ft.is_symlink() => "symlink",
2459            _ => "file",
2460        };
2461        let stats = measure_path(&entry.path(), &mut budget);
2462        aggregate.merge(&stats);
2463        largest_entries.push(LargestEntry {
2464            name,
2465            kind,
2466            bytes: stats.total_bytes,
2467        });
2468    }
2469
2470    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2471
2472    let mut out = format!("Directory inspection: {}\n\n", label);
2473    out.push_str(&format!("- Path: {}\n", path.display()));
2474    out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2475    out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2476    out.push_str(&format!(
2477        "- Recursive directories: {}\n",
2478        aggregate.dir_count
2479    ));
2480    out.push_str(&format!(
2481        "- Total size: {}{}\n",
2482        human_bytes(aggregate.total_bytes),
2483        if aggregate.partial {
2484            " (partial scan)"
2485        } else {
2486            ""
2487        }
2488    ));
2489    if aggregate.skipped_entries > 0 {
2490        out.push_str(&format!(
2491            "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2492            aggregate.skipped_entries
2493        ));
2494    }
2495
2496    if !largest_entries.is_empty() {
2497        out.push_str("\nLargest top-level entries:\n");
2498        for entry in largest_entries.iter().take(max_entries) {
2499            out.push_str(&format!(
2500                "- {} [{}] - {}\n",
2501                entry.name,
2502                entry.kind,
2503                human_bytes(entry.bytes)
2504            ));
2505        }
2506    }
2507
2508    if !sample_names.is_empty() {
2509        out.push_str("\nSample names:\n");
2510        for name in sample_names {
2511            out.push_str(&format!("- {}\n", name));
2512        }
2513    }
2514
2515    Ok(out.trim_end().to_string())
2516}
2517
2518fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2519    let trimmed = raw.trim();
2520    if trimmed.is_empty() {
2521        return Err("Path must not be empty.".to_string());
2522    }
2523
2524    if let Some(rest) = trimmed
2525        .strip_prefix("~/")
2526        .or_else(|| trimmed.strip_prefix("~\\"))
2527    {
2528        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2529        return Ok(home.join(rest));
2530    }
2531
2532    let path = PathBuf::from(trimmed);
2533    if path.is_absolute() {
2534        Ok(path)
2535    } else {
2536        let cwd =
2537            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2538        let full_path = cwd.join(&path);
2539
2540        // Heuristic: If it's a relative path to .hematite or hematite.exe and doesn't exist here,
2541        // check the user's home directory.
2542        if !full_path.exists()
2543            && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2544        {
2545            if let Some(home) = home::home_dir() {
2546                let home_path = home.join(trimmed);
2547                if home_path.exists() {
2548                    return Ok(home_path);
2549                }
2550            }
2551        }
2552
2553        Ok(full_path)
2554    }
2555}
2556
2557fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2558    workspace_mode_for_path(workspace_root)
2559}
2560
2561fn workspace_mode_for_path(path: &Path) -> &'static str {
2562    if is_project_marker_path(path) {
2563        "project"
2564    } else if path.join(".hematite").join("docs").exists()
2565        || path.join(".hematite").join("imports").exists()
2566        || path.join(".hematite").join("reports").exists()
2567    {
2568        "docs-only"
2569    } else {
2570        "general directory"
2571    }
2572}
2573
2574fn is_project_marker_path(path: &Path) -> bool {
2575    [
2576        "Cargo.toml",
2577        "package.json",
2578        "pyproject.toml",
2579        "go.mod",
2580        "composer.json",
2581        "requirements.txt",
2582        "Makefile",
2583        "justfile",
2584    ]
2585    .iter()
2586    .any(|name| path.join(name).exists())
2587        || path.join(".git").exists()
2588}
2589
2590fn preferred_shell_label() -> &'static str {
2591    #[cfg(target_os = "windows")]
2592    {
2593        "PowerShell"
2594    }
2595    #[cfg(not(target_os = "windows"))]
2596    {
2597        "sh"
2598    }
2599}
2600
2601fn desktop_dir() -> Option<PathBuf> {
2602    home::home_dir().map(|home| home.join("Desktop"))
2603}
2604
2605fn downloads_dir() -> Option<PathBuf> {
2606    home::home_dir().map(|home| home.join("Downloads"))
2607}
2608
2609fn count_top_level_items(path: &Path) -> Result<usize, String> {
2610    let mut count = 0usize;
2611    for entry in
2612        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2613    {
2614        if entry.is_ok() {
2615            count += 1;
2616        }
2617    }
2618    Ok(count)
2619}
2620
2621#[derive(Default)]
2622struct PathAggregate {
2623    total_bytes: u64,
2624    file_count: u64,
2625    dir_count: u64,
2626    skipped_entries: u64,
2627    partial: bool,
2628}
2629
2630impl PathAggregate {
2631    fn merge(&mut self, other: &PathAggregate) {
2632        self.total_bytes += other.total_bytes;
2633        self.file_count += other.file_count;
2634        self.dir_count += other.dir_count;
2635        self.skipped_entries += other.skipped_entries;
2636        self.partial |= other.partial;
2637    }
2638}
2639
2640struct LargestEntry {
2641    name: String,
2642    kind: &'static str,
2643    bytes: u64,
2644}
2645
2646fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2647    if *budget == 0 {
2648        return PathAggregate {
2649            partial: true,
2650            skipped_entries: 1,
2651            ..PathAggregate::default()
2652        };
2653    }
2654    *budget -= 1;
2655
2656    let metadata = match fs::symlink_metadata(path) {
2657        Ok(metadata) => metadata,
2658        Err(_) => {
2659            return PathAggregate {
2660                skipped_entries: 1,
2661                ..PathAggregate::default()
2662            }
2663        }
2664    };
2665
2666    let file_type = metadata.file_type();
2667    if file_type.is_symlink() {
2668        return PathAggregate {
2669            skipped_entries: 1,
2670            ..PathAggregate::default()
2671        };
2672    }
2673
2674    if metadata.is_file() {
2675        return PathAggregate {
2676            total_bytes: metadata.len(),
2677            file_count: 1,
2678            ..PathAggregate::default()
2679        };
2680    }
2681
2682    if !metadata.is_dir() {
2683        return PathAggregate::default();
2684    }
2685
2686    let mut aggregate = PathAggregate {
2687        dir_count: 1,
2688        ..PathAggregate::default()
2689    };
2690
2691    let read_dir = match fs::read_dir(path) {
2692        Ok(read_dir) => read_dir,
2693        Err(_) => {
2694            aggregate.skipped_entries += 1;
2695            return aggregate;
2696        }
2697    };
2698
2699    for child in read_dir {
2700        match child {
2701            Ok(child) => {
2702                let child_stats = measure_path(&child.path(), budget);
2703                aggregate.merge(&child_stats);
2704            }
2705            Err(_) => aggregate.skipped_entries += 1,
2706        }
2707    }
2708
2709    aggregate
2710}
2711
2712struct PathAnalysis {
2713    total_entries: usize,
2714    unique_entries: usize,
2715    entries: Vec<String>,
2716    duplicate_entries: Vec<String>,
2717    missing_entries: Vec<String>,
2718}
2719
2720fn analyze_path_env() -> PathAnalysis {
2721    let mut entries = Vec::new();
2722    let mut duplicate_entries = Vec::new();
2723    let mut missing_entries = Vec::new();
2724    let mut seen = HashSet::new();
2725
2726    let raw_path = std::env::var_os("PATH").unwrap_or_default();
2727    for path in std::env::split_paths(&raw_path) {
2728        let display = path.display().to_string();
2729        if display.trim().is_empty() {
2730            continue;
2731        }
2732
2733        let normalized = normalize_path_entry(&display);
2734        if !seen.insert(normalized) {
2735            duplicate_entries.push(display.clone());
2736        }
2737        if !path.exists() {
2738            missing_entries.push(display.clone());
2739        }
2740        entries.push(display);
2741    }
2742
2743    let total_entries = entries.len();
2744    let unique_entries = seen.len();
2745
2746    PathAnalysis {
2747        total_entries,
2748        unique_entries,
2749        entries,
2750        duplicate_entries,
2751        missing_entries,
2752    }
2753}
2754
2755fn normalize_path_entry(value: &str) -> String {
2756    #[cfg(target_os = "windows")]
2757    {
2758        value
2759            .replace('/', "\\")
2760            .trim_end_matches(['\\', '/'])
2761            .to_ascii_lowercase()
2762    }
2763    #[cfg(not(target_os = "windows"))]
2764    {
2765        value.trim_end_matches('/').to_string()
2766    }
2767}
2768
2769struct ToolchainReport {
2770    found: Vec<(String, String)>,
2771    missing: Vec<String>,
2772}
2773
2774struct PackageManagerReport {
2775    found: Vec<(String, String)>,
2776}
2777
2778#[derive(Debug, Clone)]
2779struct ProcessEntry {
2780    name: String,
2781    pid: u32,
2782    memory_bytes: u64,
2783    cpu_seconds: Option<f64>,
2784    cpu_percent: Option<f64>,
2785    read_ops: Option<u64>,
2786    write_ops: Option<u64>,
2787    detail: Option<String>,
2788}
2789
2790#[derive(Debug, Clone)]
2791struct ServiceEntry {
2792    name: String,
2793    status: String,
2794    startup: Option<String>,
2795    display_name: Option<String>,
2796    start_name: Option<String>,
2797}
2798
2799#[derive(Debug, Clone, Default)]
2800struct NetworkAdapter {
2801    name: String,
2802    ipv4: Vec<String>,
2803    ipv6: Vec<String>,
2804    gateways: Vec<String>,
2805    dns_servers: Vec<String>,
2806    disconnected: bool,
2807}
2808
2809impl NetworkAdapter {
2810    fn is_active(&self) -> bool {
2811        !self.disconnected
2812            && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2813    }
2814}
2815
2816#[derive(Debug, Clone, Copy, Default)]
2817struct ListenerExposureSummary {
2818    loopback_only: usize,
2819    wildcard_public: usize,
2820    specific_bind: usize,
2821}
2822
2823#[derive(Debug, Clone)]
2824struct ListeningPort {
2825    protocol: String,
2826    local: String,
2827    port: u16,
2828    state: String,
2829    pid: Option<String>,
2830    process_name: Option<String>,
2831}
2832
2833fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2834    #[cfg(target_os = "windows")]
2835    {
2836        collect_windows_listening_ports()
2837    }
2838    #[cfg(not(target_os = "windows"))]
2839    {
2840        collect_unix_listening_ports()
2841    }
2842}
2843
2844fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2845    #[cfg(target_os = "windows")]
2846    {
2847        collect_windows_network_adapters()
2848    }
2849    #[cfg(not(target_os = "windows"))]
2850    {
2851        collect_unix_network_adapters()
2852    }
2853}
2854
2855fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2856    #[cfg(target_os = "windows")]
2857    {
2858        collect_windows_services()
2859    }
2860    #[cfg(not(target_os = "windows"))]
2861    {
2862        collect_unix_services()
2863    }
2864}
2865
2866#[cfg(target_os = "windows")]
2867fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2868    let output = Command::new("netstat")
2869        .args(["-ano", "-p", "tcp"])
2870        .output()
2871        .map_err(|e| format!("Failed to run netstat: {e}"))?;
2872    if !output.status.success() {
2873        return Err("netstat returned a non-success status.".to_string());
2874    }
2875
2876    let text = String::from_utf8_lossy(&output.stdout);
2877    let mut listeners = Vec::new();
2878    for line in text.lines() {
2879        let trimmed = line.trim();
2880        if !trimmed.starts_with("TCP") {
2881            continue;
2882        }
2883        let cols: Vec<&str> = trimmed.split_whitespace().collect();
2884        if cols.len() < 5 || cols[3] != "LISTENING" {
2885            continue;
2886        }
2887        let Some(port) = extract_port_from_socket(cols[1]) else {
2888            continue;
2889        };
2890        listeners.push(ListeningPort {
2891            protocol: cols[0].to_string(),
2892            local: cols[1].to_string(),
2893            port,
2894            state: cols[3].to_string(),
2895            pid: Some(cols[4].to_string()),
2896            process_name: None,
2897        });
2898    }
2899
2900    // Enrich with process names via PowerShell — works without elevation for
2901    // most user-space processes. System processes (PID 4, etc.) stay unnamed.
2902    let unique_pids: Vec<String> = listeners
2903        .iter()
2904        .filter_map(|l| l.pid.clone())
2905        .collect::<HashSet<_>>()
2906        .into_iter()
2907        .collect();
2908
2909    if !unique_pids.is_empty() {
2910        let pid_list = unique_pids.join(",");
2911        let ps_cmd = format!(
2912            "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2913            pid_list
2914        );
2915        if let Ok(ps_out) = Command::new("powershell")
2916            .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2917            .output()
2918        {
2919            let mut pid_map = std::collections::HashMap::<String, String>::new();
2920            let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2921            for line in ps_text.lines() {
2922                let parts: Vec<&str> = line.split_whitespace().collect();
2923                if parts.len() >= 2 {
2924                    pid_map.insert(parts[0].to_string(), parts[1].to_string());
2925                }
2926            }
2927            for listener in &mut listeners {
2928                if let Some(pid) = &listener.pid {
2929                    listener.process_name = pid_map.get(pid).cloned();
2930                }
2931            }
2932        }
2933    }
2934
2935    Ok(listeners)
2936}
2937
2938#[cfg(not(target_os = "windows"))]
2939fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2940    let output = Command::new("ss")
2941        .args(["-ltn"])
2942        .output()
2943        .map_err(|e| format!("Failed to run ss: {e}"))?;
2944    if !output.status.success() {
2945        return Err("ss returned a non-success status.".to_string());
2946    }
2947
2948    let text = String::from_utf8_lossy(&output.stdout);
2949    let mut listeners = Vec::new();
2950    for line in text.lines().skip(1) {
2951        let cols: Vec<&str> = line.split_whitespace().collect();
2952        if cols.len() < 4 {
2953            continue;
2954        }
2955        let Some(port) = extract_port_from_socket(cols[3]) else {
2956            continue;
2957        };
2958        listeners.push(ListeningPort {
2959            protocol: "tcp".to_string(),
2960            local: cols[3].to_string(),
2961            port,
2962            state: cols[0].to_string(),
2963            pid: None,
2964            process_name: None,
2965        });
2966    }
2967
2968    Ok(listeners)
2969}
2970
2971fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
2972    #[cfg(target_os = "windows")]
2973    {
2974        collect_windows_processes()
2975    }
2976    #[cfg(not(target_os = "windows"))]
2977    {
2978        collect_unix_processes()
2979    }
2980}
2981
2982#[cfg(target_os = "windows")]
2983fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
2984    let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
2985    let output = Command::new("powershell")
2986        .args(["-NoProfile", "-Command", command])
2987        .output()
2988        .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
2989    if !output.status.success() {
2990        return Err("PowerShell service inspection returned a non-success status.".to_string());
2991    }
2992
2993    parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
2994}
2995
2996#[cfg(not(target_os = "windows"))]
2997fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
2998    let status_output = Command::new("systemctl")
2999        .args([
3000            "list-units",
3001            "--type=service",
3002            "--all",
3003            "--no-pager",
3004            "--no-legend",
3005            "--plain",
3006        ])
3007        .output()
3008        .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
3009    if !status_output.status.success() {
3010        return Err("systemctl list-units returned a non-success status.".to_string());
3011    }
3012
3013    let startup_output = Command::new("systemctl")
3014        .args([
3015            "list-unit-files",
3016            "--type=service",
3017            "--no-legend",
3018            "--no-pager",
3019            "--plain",
3020        ])
3021        .output()
3022        .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
3023    if !startup_output.status.success() {
3024        return Err("systemctl list-unit-files returned a non-success status.".to_string());
3025    }
3026
3027    Ok(parse_unix_services(
3028        &String::from_utf8_lossy(&status_output.stdout),
3029        &String::from_utf8_lossy(&startup_output.stdout),
3030    ))
3031}
3032
3033#[cfg(target_os = "windows")]
3034fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3035    let output = Command::new("ipconfig")
3036        .args(["/all"])
3037        .output()
3038        .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
3039    if !output.status.success() {
3040        return Err("ipconfig returned a non-success status.".to_string());
3041    }
3042
3043    Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
3044        &output.stdout,
3045    )))
3046}
3047
3048#[cfg(not(target_os = "windows"))]
3049fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
3050    let addr_output = Command::new("ip")
3051        .args(["-o", "addr", "show", "up"])
3052        .output()
3053        .map_err(|e| format!("Failed to run ip addr: {e}"))?;
3054    if !addr_output.status.success() {
3055        return Err("ip addr returned a non-success status.".to_string());
3056    }
3057
3058    let route_output = Command::new("ip")
3059        .args(["route", "show", "default"])
3060        .output()
3061        .map_err(|e| format!("Failed to run ip route: {e}"))?;
3062    if !route_output.status.success() {
3063        return Err("ip route returned a non-success status.".to_string());
3064    }
3065
3066    let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
3067    apply_unix_default_routes(
3068        &mut adapters,
3069        &String::from_utf8_lossy(&route_output.stdout),
3070    );
3071    apply_unix_dns_servers(&mut adapters);
3072    Ok(adapters)
3073}
3074
3075#[cfg(target_os = "windows")]
3076fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
3077    // We take two samples of CPU time separated by a short interval to calculate recent CPU %
3078    let script = r#"
3079        $s1 = Get-Process | Select-Object Id, CPU
3080        Start-Sleep -Milliseconds 250
3081        $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
3082        $s2 | ForEach-Object {
3083            $p2 = $_
3084            $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
3085            $pct = 0.0
3086            if ($p1 -and $p2.CPU -gt $p1.CPU) {
3087                # (Delta CPU seconds / interval) * 100 / LogicalProcessors
3088                # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
3089                # Standard Task Manager style is (delta / interval) * 100.
3090                $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
3091            }
3092            "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
3093        }
3094    "#;
3095
3096    let output = Command::new("powershell")
3097        .args(["-NoProfile", "-Command", script])
3098        .output()
3099        .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
3100
3101    let text = String::from_utf8_lossy(&output.stdout);
3102    let mut out = Vec::new();
3103    for line in text.lines() {
3104        let parts: Vec<&str> = line.trim().split('|').collect();
3105        if parts.len() < 5 {
3106            continue;
3107        }
3108        let mut entry = ProcessEntry {
3109            name: "unknown".to_string(),
3110            pid: 0,
3111            memory_bytes: 0,
3112            cpu_seconds: None,
3113            cpu_percent: None,
3114            read_ops: None,
3115            write_ops: None,
3116            detail: None,
3117        };
3118        for p in parts {
3119            if let Some((k, v)) = p.split_once(':') {
3120                match k {
3121                    "PID" => entry.pid = v.parse().unwrap_or(0),
3122                    "NAME" => entry.name = v.to_string(),
3123                    "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
3124                    "CPU_S" => entry.cpu_seconds = v.parse().ok(),
3125                    "CPU_P" => entry.cpu_percent = v.parse().ok(),
3126                    "READ" => entry.read_ops = v.parse().ok(),
3127                    "WRITE" => entry.write_ops = v.parse().ok(),
3128                    _ => {}
3129                }
3130            }
3131        }
3132        out.push(entry);
3133    }
3134    Ok(out)
3135}
3136
3137#[cfg(not(target_os = "windows"))]
3138fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
3139    let output = Command::new("ps")
3140        .args(["-eo", "pid=,rss=,comm="])
3141        .output()
3142        .map_err(|e| format!("Failed to run ps: {e}"))?;
3143    if !output.status.success() {
3144        return Err("ps returned a non-success status.".to_string());
3145    }
3146
3147    let text = String::from_utf8_lossy(&output.stdout);
3148    let mut processes = Vec::new();
3149    for line in text.lines() {
3150        let cols: Vec<&str> = line.split_whitespace().collect();
3151        if cols.len() < 3 {
3152            continue;
3153        }
3154        let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
3155        else {
3156            continue;
3157        };
3158        processes.push(ProcessEntry {
3159            name: cols[2..].join(" "),
3160            pid,
3161            memory_bytes: rss_kib * 1024,
3162            cpu_seconds: None,
3163            cpu_percent: None,
3164            read_ops: None,
3165            write_ops: None,
3166            detail: None,
3167        });
3168    }
3169
3170    Ok(processes)
3171}
3172
3173fn extract_port_from_socket(value: &str) -> Option<u16> {
3174    let cleaned = value.trim().trim_matches(['[', ']']);
3175    let port_str = cleaned.rsplit(':').next()?;
3176    port_str.parse::<u16>().ok()
3177}
3178
3179fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3180    let mut summary = ListenerExposureSummary::default();
3181    for entry in listeners {
3182        let local = entry.local.to_ascii_lowercase();
3183        if is_loopback_listener(&local) {
3184            summary.loopback_only += 1;
3185        } else if is_wildcard_listener(&local) {
3186            summary.wildcard_public += 1;
3187        } else {
3188            summary.specific_bind += 1;
3189        }
3190    }
3191    summary
3192}
3193
3194fn is_loopback_listener(local: &str) -> bool {
3195    local.starts_with("127.")
3196        || local.starts_with("[::1]")
3197        || local.starts_with("::1")
3198        || local.starts_with("localhost:")
3199}
3200
3201fn is_wildcard_listener(local: &str) -> bool {
3202    local.starts_with("0.0.0.0:")
3203        || local.starts_with("[::]:")
3204        || local.starts_with(":::")
3205        || local == "*:*"
3206}
3207
3208struct GitState {
3209    root: PathBuf,
3210    branch: String,
3211    dirty_entries: usize,
3212}
3213
3214impl GitState {
3215    fn status_label(&self) -> String {
3216        if self.dirty_entries == 0 {
3217            "clean".to_string()
3218        } else {
3219            format!("dirty ({} changed path(s))", self.dirty_entries)
3220        }
3221    }
3222}
3223
3224fn inspect_git_state(path: &Path) -> Option<GitState> {
3225    let root = capture_first_line(
3226        "git",
3227        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3228    )?;
3229    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3230        .unwrap_or_else(|| "detached".to_string());
3231    let output = Command::new("git")
3232        .args(["-C", path.to_str()?, "status", "--short"])
3233        .output()
3234        .ok()?;
3235    if !output.status.success() {
3236        return None;
3237    }
3238    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3239    Some(GitState {
3240        root: PathBuf::from(root),
3241        branch,
3242        dirty_entries,
3243    })
3244}
3245
3246struct HematiteState {
3247    docs_count: usize,
3248    import_count: usize,
3249    report_count: usize,
3250    workspace_profile: bool,
3251}
3252
3253fn collect_hematite_state(path: &Path) -> HematiteState {
3254    let root = path.join(".hematite");
3255    HematiteState {
3256        docs_count: count_entries_if_exists(&root.join("docs")),
3257        import_count: count_entries_if_exists(&root.join("imports")),
3258        report_count: count_entries_if_exists(&root.join("reports")),
3259        workspace_profile: root.join("workspace_profile.json").exists(),
3260    }
3261}
3262
3263fn count_entries_if_exists(path: &Path) -> usize {
3264    if !path.exists() || !path.is_dir() {
3265        return 0;
3266    }
3267    fs::read_dir(path)
3268        .ok()
3269        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3270        .unwrap_or(0)
3271}
3272
3273fn collect_project_markers(path: &Path) -> Vec<String> {
3274    [
3275        "Cargo.toml",
3276        "package.json",
3277        "pyproject.toml",
3278        "go.mod",
3279        "justfile",
3280        "Makefile",
3281        ".git",
3282    ]
3283    .iter()
3284    .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3285    .collect()
3286}
3287
3288struct ReleaseArtifactState {
3289    version: String,
3290    portable_dir: bool,
3291    portable_zip: bool,
3292    setup_exe: bool,
3293}
3294
3295fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3296    let cargo_toml = path.join("Cargo.toml");
3297    if !cargo_toml.exists() {
3298        return None;
3299    }
3300    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3301    let version = [regex_line_capture(
3302        &cargo_text,
3303        r#"(?m)^version\s*=\s*"([^"]+)""#,
3304    )?]
3305    .concat();
3306    let dist_windows = path.join("dist").join("windows");
3307    let prefix = format!("Hematite-{}", version);
3308    Some(ReleaseArtifactState {
3309        version,
3310        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3311        portable_zip: dist_windows
3312            .join(format!("{}-portable.zip", prefix))
3313            .exists(),
3314        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3315    })
3316}
3317
3318fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3319    let regex = regex::Regex::new(pattern).ok()?;
3320    let captures = regex.captures(text)?;
3321    captures.get(1).map(|m| m.as_str().to_string())
3322}
3323
3324fn bool_label(value: bool) -> &'static str {
3325    if value {
3326        "yes"
3327    } else {
3328        "no"
3329    }
3330}
3331
3332fn collect_toolchains() -> ToolchainReport {
3333    let checks = [
3334        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3335        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3336        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3337        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3338        ToolCheck::new(
3339            "npm",
3340            &[
3341                CommandProbe::new("npm", &["--version"]),
3342                CommandProbe::new("npm.cmd", &["--version"]),
3343            ],
3344        ),
3345        ToolCheck::new(
3346            "pnpm",
3347            &[
3348                CommandProbe::new("pnpm", &["--version"]),
3349                CommandProbe::new("pnpm.cmd", &["--version"]),
3350            ],
3351        ),
3352        ToolCheck::new(
3353            "python",
3354            &[
3355                CommandProbe::new("python", &["--version"]),
3356                CommandProbe::new("python3", &["--version"]),
3357                CommandProbe::new("py", &["-3", "--version"]),
3358                CommandProbe::new("py", &["--version"]),
3359            ],
3360        ),
3361        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3362        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3363        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3364        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3365    ];
3366
3367    let mut found = Vec::new();
3368    let mut missing = Vec::new();
3369
3370    for check in checks {
3371        match check.detect() {
3372            Some(version) => found.push((check.label.to_string(), version)),
3373            None => missing.push(check.label.to_string()),
3374        }
3375    }
3376
3377    ToolchainReport { found, missing }
3378}
3379
3380fn collect_package_managers() -> PackageManagerReport {
3381    let checks = [
3382        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3383        ToolCheck::new(
3384            "npm",
3385            &[
3386                CommandProbe::new("npm", &["--version"]),
3387                CommandProbe::new("npm.cmd", &["--version"]),
3388            ],
3389        ),
3390        ToolCheck::new(
3391            "pnpm",
3392            &[
3393                CommandProbe::new("pnpm", &["--version"]),
3394                CommandProbe::new("pnpm.cmd", &["--version"]),
3395            ],
3396        ),
3397        ToolCheck::new(
3398            "pip",
3399            &[
3400                CommandProbe::new("python", &["-m", "pip", "--version"]),
3401                CommandProbe::new("python3", &["-m", "pip", "--version"]),
3402                CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3403                CommandProbe::new("py", &["-m", "pip", "--version"]),
3404                CommandProbe::new("pip", &["--version"]),
3405            ],
3406        ),
3407        ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3408        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3409        ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3410        ToolCheck::new(
3411            "choco",
3412            &[
3413                CommandProbe::new("choco", &["--version"]),
3414                CommandProbe::new("choco.exe", &["--version"]),
3415            ],
3416        ),
3417        ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3418    ];
3419
3420    let mut found = Vec::new();
3421    for check in checks {
3422        match check.detect() {
3423            Some(version) => found.push((check.label.to_string(), version)),
3424            None => {}
3425        }
3426    }
3427
3428    PackageManagerReport { found }
3429}
3430
3431#[derive(Clone)]
3432struct ToolCheck {
3433    label: &'static str,
3434    probes: Vec<CommandProbe>,
3435}
3436
3437impl ToolCheck {
3438    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3439        Self {
3440            label,
3441            probes: probes.to_vec(),
3442        }
3443    }
3444
3445    fn detect(&self) -> Option<String> {
3446        for probe in &self.probes {
3447            if let Some(output) = capture_first_line(probe.program, probe.args) {
3448                return Some(output);
3449            }
3450        }
3451        None
3452    }
3453}
3454
3455#[derive(Clone, Copy)]
3456struct CommandProbe {
3457    program: &'static str,
3458    args: &'static [&'static str],
3459}
3460
3461impl CommandProbe {
3462    const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
3463        Self { program, args }
3464    }
3465}
3466
3467fn build_env_doctor_findings(
3468    toolchains: &ToolchainReport,
3469    package_managers: &PackageManagerReport,
3470    path_stats: &PathAnalysis,
3471) -> Vec<String> {
3472    let found_tools = toolchains
3473        .found
3474        .iter()
3475        .map(|(label, _)| label.as_str())
3476        .collect::<HashSet<_>>();
3477    let found_managers = package_managers
3478        .found
3479        .iter()
3480        .map(|(label, _)| label.as_str())
3481        .collect::<HashSet<_>>();
3482
3483    let mut findings = Vec::new();
3484
3485    if path_stats.duplicate_entries.len() > 0 {
3486        findings.push(format!(
3487            "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3488            path_stats.duplicate_entries.len()
3489        ));
3490    }
3491    if path_stats.missing_entries.len() > 0 {
3492        findings.push(format!(
3493            "PATH contains {} entries that do not exist on disk.",
3494            path_stats.missing_entries.len()
3495        ));
3496    }
3497    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3498        findings.push(
3499            "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3500                .to_string(),
3501        );
3502    }
3503    if found_tools.contains("node")
3504        && !found_managers.contains("npm")
3505        && !found_managers.contains("pnpm")
3506    {
3507        findings.push(
3508            "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3509                .to_string(),
3510        );
3511    }
3512    if found_tools.contains("python")
3513        && !found_managers.contains("pip")
3514        && !found_managers.contains("uv")
3515        && !found_managers.contains("pipx")
3516    {
3517        findings.push(
3518            "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3519                .to_string(),
3520        );
3521    }
3522    let windows_manager_count = ["winget", "choco", "scoop"]
3523        .iter()
3524        .filter(|label| found_managers.contains(**label))
3525        .count();
3526    if windows_manager_count > 1 {
3527        findings.push(
3528            "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3529                .to_string(),
3530        );
3531    }
3532    if findings.is_empty() && !found_managers.is_empty() {
3533        findings.push(
3534            "Core package-manager coverage looks healthy for a normal developer workstation."
3535                .to_string(),
3536        );
3537    }
3538
3539    findings
3540}
3541
3542fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
3543    let output = std::process::Command::new(program)
3544        .args(args)
3545        .output()
3546        .ok()?;
3547    if !output.status.success() {
3548        return None;
3549    }
3550
3551    let stdout = if output.stdout.is_empty() {
3552        String::from_utf8_lossy(&output.stderr).into_owned()
3553    } else {
3554        String::from_utf8_lossy(&output.stdout).into_owned()
3555    };
3556
3557    stdout
3558        .lines()
3559        .map(str::trim)
3560        .find(|line| !line.is_empty())
3561        .map(|line| line.to_string())
3562}
3563
3564fn human_bytes(bytes: u64) -> String {
3565    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3566    let mut value = bytes as f64;
3567    let mut unit_index = 0usize;
3568
3569    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3570        value /= 1024.0;
3571        unit_index += 1;
3572    }
3573
3574    if unit_index == 0 {
3575        format!("{} {}", bytes, UNITS[unit_index])
3576    } else {
3577        format!("{value:.1} {}", UNITS[unit_index])
3578    }
3579}
3580
3581#[cfg(target_os = "windows")]
3582fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3583    let mut adapters = Vec::new();
3584    let mut current: Option<NetworkAdapter> = None;
3585    let mut pending_dns = false;
3586
3587    for raw_line in text.lines() {
3588        let line = raw_line.trim_end();
3589        let trimmed = line.trim();
3590        if trimmed.is_empty() {
3591            pending_dns = false;
3592            continue;
3593        }
3594
3595        if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3596            if let Some(adapter) = current.take() {
3597                adapters.push(adapter);
3598            }
3599            current = Some(NetworkAdapter {
3600                name: trimmed.trim_end_matches(':').to_string(),
3601                ..NetworkAdapter::default()
3602            });
3603            pending_dns = false;
3604            continue;
3605        }
3606
3607        let Some(adapter) = current.as_mut() else {
3608            continue;
3609        };
3610
3611        if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3612            adapter.disconnected = true;
3613        }
3614
3615        if let Some(value) = value_after_colon(trimmed) {
3616            let normalized = normalize_ipconfig_value(value);
3617            if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3618                adapter.ipv4.push(normalized);
3619                pending_dns = false;
3620            } else if trimmed.starts_with("IPv6 Address")
3621                || trimmed.starts_with("Temporary IPv6 Address")
3622                || trimmed.starts_with("Link-local IPv6 Address")
3623            {
3624                if !normalized.is_empty() {
3625                    adapter.ipv6.push(normalized);
3626                }
3627                pending_dns = false;
3628            } else if trimmed.starts_with("Default Gateway") {
3629                if !normalized.is_empty() {
3630                    adapter.gateways.push(normalized);
3631                }
3632                pending_dns = false;
3633            } else if trimmed.starts_with("DNS Servers") {
3634                if !normalized.is_empty() {
3635                    adapter.dns_servers.push(normalized);
3636                }
3637                pending_dns = true;
3638            } else {
3639                pending_dns = false;
3640            }
3641        } else if pending_dns {
3642            let normalized = normalize_ipconfig_value(trimmed);
3643            if !normalized.is_empty() {
3644                adapter.dns_servers.push(normalized);
3645            }
3646        }
3647    }
3648
3649    if let Some(adapter) = current.take() {
3650        adapters.push(adapter);
3651    }
3652
3653    for adapter in &mut adapters {
3654        dedup_vec(&mut adapter.ipv4);
3655        dedup_vec(&mut adapter.ipv6);
3656        dedup_vec(&mut adapter.gateways);
3657        dedup_vec(&mut adapter.dns_servers);
3658    }
3659
3660    adapters
3661}
3662
3663#[cfg(not(target_os = "windows"))]
3664fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3665    let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3666
3667    for line in text.lines() {
3668        let cols: Vec<&str> = line.split_whitespace().collect();
3669        if cols.len() < 4 {
3670            continue;
3671        }
3672        let name = cols[1].trim_end_matches(':').to_string();
3673        let family = cols[2];
3674        let addr = cols[3].split('/').next().unwrap_or("").to_string();
3675        let entry = adapters
3676            .entry(name.clone())
3677            .or_insert_with(|| NetworkAdapter {
3678                name,
3679                ..NetworkAdapter::default()
3680            });
3681        match family {
3682            "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3683            "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3684            _ => {}
3685        }
3686    }
3687
3688    adapters.into_values().collect()
3689}
3690
3691#[cfg(not(target_os = "windows"))]
3692fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3693    for line in text.lines() {
3694        let cols: Vec<&str> = line.split_whitespace().collect();
3695        if cols.len() < 5 {
3696            continue;
3697        }
3698        let gateway = cols
3699            .windows(2)
3700            .find(|pair| pair[0] == "via")
3701            .map(|pair| pair[1].to_string());
3702        let dev = cols
3703            .windows(2)
3704            .find(|pair| pair[0] == "dev")
3705            .map(|pair| pair[1]);
3706        if let (Some(gateway), Some(dev)) = (gateway, dev) {
3707            if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3708                adapter.gateways.push(gateway);
3709            }
3710        }
3711    }
3712
3713    for adapter in adapters {
3714        dedup_vec(&mut adapter.gateways);
3715    }
3716}
3717
3718#[cfg(not(target_os = "windows"))]
3719fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3720    let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3721        return;
3722    };
3723    let mut dns_servers = text
3724        .lines()
3725        .filter_map(|line| line.strip_prefix("nameserver "))
3726        .map(str::trim)
3727        .filter(|value| !value.is_empty())
3728        .map(|value| value.to_string())
3729        .collect::<Vec<_>>();
3730    dedup_vec(&mut dns_servers);
3731    if dns_servers.is_empty() {
3732        return;
3733    }
3734    for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3735        adapter.dns_servers = dns_servers.clone();
3736    }
3737}
3738
3739#[cfg(target_os = "windows")]
3740fn value_after_colon(line: &str) -> Option<&str> {
3741    line.split_once(':').map(|(_, value)| value.trim())
3742}
3743
3744#[cfg(target_os = "windows")]
3745fn normalize_ipconfig_value(value: &str) -> String {
3746    value
3747        .trim()
3748        .trim_end_matches("(Preferred)")
3749        .trim_end_matches("(Deprecated)")
3750        .trim()
3751        .trim_matches(['(', ')'])
3752        .trim()
3753        .to_string()
3754}
3755
3756#[cfg(target_os = "windows")]
3757fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3758    let mac_upper = mac.to_ascii_uppercase();
3759    if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3760        return true;
3761    }
3762
3763    ip == "255.255.255.255"
3764        || ip.starts_with("224.")
3765        || ip.starts_with("225.")
3766        || ip.starts_with("226.")
3767        || ip.starts_with("227.")
3768        || ip.starts_with("228.")
3769        || ip.starts_with("229.")
3770        || ip.starts_with("230.")
3771        || ip.starts_with("231.")
3772        || ip.starts_with("232.")
3773        || ip.starts_with("233.")
3774        || ip.starts_with("234.")
3775        || ip.starts_with("235.")
3776        || ip.starts_with("236.")
3777        || ip.starts_with("237.")
3778        || ip.starts_with("238.")
3779        || ip.starts_with("239.")
3780}
3781
3782fn dedup_vec(values: &mut Vec<String>) {
3783    let mut seen = HashSet::new();
3784    values.retain(|value| seen.insert(value.clone()));
3785}
3786
3787#[cfg(target_os = "windows")]
3788fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3789    let trimmed = text.trim();
3790    if trimmed.is_empty() {
3791        return Vec::new();
3792    }
3793
3794    let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3795        return Vec::new();
3796    };
3797    let entries = match value {
3798        Value::Array(items) => items,
3799        other => vec![other],
3800    };
3801
3802    let mut neighbors = Vec::new();
3803    for entry in entries {
3804        let ip = entry
3805            .get("IPAddress")
3806            .and_then(|v| v.as_str())
3807            .unwrap_or("")
3808            .to_string();
3809        if ip.is_empty() {
3810            continue;
3811        }
3812        let mac = entry
3813            .get("LinkLayerAddress")
3814            .and_then(|v| v.as_str())
3815            .unwrap_or("unknown")
3816            .to_string();
3817        let state = entry
3818            .get("State")
3819            .and_then(|v| v.as_str())
3820            .unwrap_or("unknown")
3821            .to_string();
3822        let iface = entry
3823            .get("InterfaceAlias")
3824            .and_then(|v| v.as_str())
3825            .unwrap_or("unknown")
3826            .to_string();
3827        if is_noise_lan_neighbor(&ip, &mac) {
3828            continue;
3829        }
3830        neighbors.push((ip, mac, state, iface));
3831    }
3832
3833    neighbors
3834}
3835
3836#[cfg(target_os = "windows")]
3837fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3838    let trimmed = text.trim();
3839    if trimmed.is_empty() {
3840        return Ok(Vec::new());
3841    }
3842
3843    let value: Value = serde_json::from_str(trimmed)
3844        .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3845    let entries = match value {
3846        Value::Array(items) => items,
3847        other => vec![other],
3848    };
3849
3850    let mut services = Vec::new();
3851    for entry in entries {
3852        let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3853            continue;
3854        };
3855        services.push(ServiceEntry {
3856            name: name.to_string(),
3857            status: entry
3858                .get("State")
3859                .and_then(|v| v.as_str())
3860                .unwrap_or("unknown")
3861                .to_string(),
3862            startup: entry
3863                .get("StartMode")
3864                .and_then(|v| v.as_str())
3865                .map(|v| v.to_string()),
3866            display_name: entry
3867                .get("DisplayName")
3868                .and_then(|v| v.as_str())
3869                .map(|v| v.to_string()),
3870            start_name: entry
3871                .get("StartName")
3872                .and_then(|v| v.as_str())
3873                .map(|v| v.to_string()),
3874        });
3875    }
3876
3877    Ok(services)
3878}
3879
3880#[cfg(target_os = "windows")]
3881fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3882    match node.cloned() {
3883        Some(Value::Array(items)) => items,
3884        Some(other) => vec![other],
3885        None => Vec::new(),
3886    }
3887}
3888
3889#[cfg(target_os = "windows")]
3890fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3891    windows_json_entries(node)
3892        .into_iter()
3893        .filter_map(|entry| {
3894            let name = entry
3895                .get("FriendlyName")
3896                .and_then(|v| v.as_str())
3897                .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3898                .unwrap_or("")
3899                .trim()
3900                .to_string();
3901            if name.is_empty() {
3902                return None;
3903            }
3904            Some(WindowsPnpDevice {
3905                name,
3906                status: entry
3907                    .get("Status")
3908                    .and_then(|v| v.as_str())
3909                    .unwrap_or("Unknown")
3910                    .trim()
3911                    .to_string(),
3912                problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3913                    entry
3914                        .get("Problem")
3915                        .and_then(|v| v.as_i64())
3916                        .map(|v| v as u64)
3917                }),
3918                class_name: entry
3919                    .get("Class")
3920                    .and_then(|v| v.as_str())
3921                    .map(|v| v.trim().to_string()),
3922                instance_id: entry
3923                    .get("InstanceId")
3924                    .and_then(|v| v.as_str())
3925                    .map(|v| v.trim().to_string()),
3926            })
3927        })
3928        .collect()
3929}
3930
3931#[cfg(target_os = "windows")]
3932fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
3933    windows_json_entries(node)
3934        .into_iter()
3935        .filter_map(|entry| {
3936            let name = entry
3937                .get("Name")
3938                .and_then(|v| v.as_str())
3939                .unwrap_or("")
3940                .trim()
3941                .to_string();
3942            if name.is_empty() {
3943                return None;
3944            }
3945            Some(WindowsSoundDevice {
3946                name,
3947                status: entry
3948                    .get("Status")
3949                    .and_then(|v| v.as_str())
3950                    .unwrap_or("Unknown")
3951                    .trim()
3952                    .to_string(),
3953                manufacturer: entry
3954                    .get("Manufacturer")
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 windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
3964    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
3965        || device.problem.unwrap_or(0) != 0
3966}
3967
3968#[cfg(target_os = "windows")]
3969fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
3970    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
3971}
3972
3973#[cfg(target_os = "windows")]
3974fn is_microphone_like_name(name: &str) -> bool {
3975    let lower = name.to_ascii_lowercase();
3976    lower.contains("microphone")
3977        || lower.contains("mic")
3978        || lower.contains("input")
3979        || lower.contains("array")
3980        || lower.contains("capture")
3981        || lower.contains("record")
3982}
3983
3984#[cfg(target_os = "windows")]
3985fn is_bluetooth_like_name(name: &str) -> bool {
3986    let lower = name.to_ascii_lowercase();
3987    lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
3988}
3989
3990#[cfg(target_os = "windows")]
3991fn service_is_running(service: &ServiceEntry) -> bool {
3992    service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
3993}
3994
3995#[cfg(not(target_os = "windows"))]
3996fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
3997    let mut startup_modes = std::collections::HashMap::<String, String>::new();
3998    for line in startup_text.lines() {
3999        let cols: Vec<&str> = line.split_whitespace().collect();
4000        if cols.len() < 2 {
4001            continue;
4002        }
4003        startup_modes.insert(cols[0].to_string(), cols[1].to_string());
4004    }
4005
4006    let mut services = Vec::new();
4007    for line in status_text.lines() {
4008        let cols: Vec<&str> = line.split_whitespace().collect();
4009        if cols.len() < 4 {
4010            continue;
4011        }
4012        let unit = cols[0];
4013        let load = cols[1];
4014        let active = cols[2];
4015        let sub = cols[3];
4016        let description = if cols.len() > 4 {
4017            Some(cols[4..].join(" "))
4018        } else {
4019            None
4020        };
4021        services.push(ServiceEntry {
4022            name: unit.to_string(),
4023            status: format!("{}/{}", active, sub),
4024            startup: startup_modes
4025                .get(unit)
4026                .cloned()
4027                .or_else(|| Some(load.to_string())),
4028            display_name: description,
4029            start_name: None,
4030        });
4031    }
4032
4033    services
4034}
4035
4036// ── health_report ─────────────────────────────────────────────────────────────
4037
4038/// Synthesized system health report — runs multiple checks and returns a
4039/// plain-English tiered verdict suitable for both developers and non-technical
4040/// users who just want to know if their machine is okay.
4041fn inspect_health_report() -> Result<String, String> {
4042    let mut needs_fix: Vec<String> = Vec::new();
4043    let mut watch: Vec<String> = Vec::new();
4044    let mut good: Vec<String> = Vec::new();
4045    let mut tips: Vec<String> = Vec::new();
4046
4047    health_check_disk(&mut needs_fix, &mut watch, &mut good);
4048    health_check_memory(&mut watch, &mut good);
4049    health_check_tools(&mut watch, &mut good, &mut tips);
4050    health_check_recent_errors(&mut watch, &mut tips);
4051
4052    let overall = if !needs_fix.is_empty() {
4053        "ACTION REQUIRED"
4054    } else if !watch.is_empty() {
4055        "WORTH A LOOK"
4056    } else {
4057        "ALL GOOD"
4058    };
4059
4060    let mut out = format!("System Health Report — {overall}\n\n");
4061
4062    if !needs_fix.is_empty() {
4063        out.push_str("Needs fixing:\n");
4064        for item in &needs_fix {
4065            out.push_str(&format!("  [!] {item}\n"));
4066        }
4067        out.push('\n');
4068    }
4069    if !watch.is_empty() {
4070        out.push_str("Worth watching:\n");
4071        for item in &watch {
4072            out.push_str(&format!("  [-] {item}\n"));
4073        }
4074        out.push('\n');
4075    }
4076    if !good.is_empty() {
4077        out.push_str("Looking good:\n");
4078        for item in &good {
4079            out.push_str(&format!("  [+] {item}\n"));
4080        }
4081        out.push('\n');
4082    }
4083    if !tips.is_empty() {
4084        out.push_str("To dig deeper:\n");
4085        for tip in &tips {
4086            out.push_str(&format!("  {tip}\n"));
4087        }
4088    }
4089
4090    Ok(out.trim_end().to_string())
4091}
4092
4093fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
4094    #[cfg(target_os = "windows")]
4095    {
4096        let script = r#"try {
4097    $d = Get-PSDrive C -ErrorAction Stop
4098    "$($d.Free)|$($d.Used)"
4099} catch { "ERR" }"#;
4100        if let Ok(out) = Command::new("powershell")
4101            .args(["-NoProfile", "-Command", script])
4102            .output()
4103        {
4104            let text = String::from_utf8_lossy(&out.stdout);
4105            let text = text.trim();
4106            if !text.starts_with("ERR") {
4107                let parts: Vec<&str> = text.split('|').collect();
4108                if parts.len() == 2 {
4109                    let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
4110                    let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
4111                    let total = free_bytes + used_bytes;
4112                    let free_gb = free_bytes / 1_073_741_824;
4113                    let pct_free = if total > 0 {
4114                        (free_bytes as f64 / total as f64 * 100.0) as u64
4115                    } else {
4116                        0
4117                    };
4118                    let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
4119                    if free_gb < 5 {
4120                        needs_fix.push(format!(
4121                            "{msg} — very low. Free up space or your system may slow down or stop working."
4122                        ));
4123                    } else if free_gb < 15 {
4124                        watch.push(format!("{msg} — getting low, consider cleaning up."));
4125                    } else {
4126                        good.push(msg);
4127                    }
4128                    return;
4129                }
4130            }
4131        }
4132        watch.push("Disk: could not read free space from C: drive.".to_string());
4133    }
4134
4135    #[cfg(not(target_os = "windows"))]
4136    {
4137        if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
4138            let text = String::from_utf8_lossy(&out.stdout);
4139            for line in text.lines().skip(1) {
4140                let cols: Vec<&str> = line.split_whitespace().collect();
4141                if cols.len() >= 5 {
4142                    let avail_str = cols[3].trim_end_matches('G');
4143                    let use_pct = cols[4].trim_end_matches('%');
4144                    let avail_gb: u64 = avail_str.parse().unwrap_or(0);
4145                    let used_pct: u64 = use_pct.parse().unwrap_or(0);
4146                    let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
4147                    if avail_gb < 5 {
4148                        needs_fix.push(format!(
4149                            "{msg} — very low. Free up space to prevent system issues."
4150                        ));
4151                    } else if avail_gb < 15 {
4152                        watch.push(format!("{msg} — getting low."));
4153                    } else {
4154                        good.push(msg);
4155                    }
4156                    return;
4157                }
4158            }
4159        }
4160        watch.push("Disk: could not determine free space.".to_string());
4161    }
4162}
4163
4164fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4165    #[cfg(target_os = "windows")]
4166    {
4167        let script = r#"try {
4168    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4169    "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4170} catch { "ERR" }"#;
4171        if let Ok(out) = Command::new("powershell")
4172            .args(["-NoProfile", "-Command", script])
4173            .output()
4174        {
4175            let text = String::from_utf8_lossy(&out.stdout);
4176            let text = text.trim();
4177            if !text.starts_with("ERR") {
4178                let parts: Vec<&str> = text.split('|').collect();
4179                if parts.len() == 2 {
4180                    let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4181                    let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4182                    if total_kb > 0 {
4183                        let free_gb = free_kb / 1_048_576;
4184                        let total_gb = total_kb / 1_048_576;
4185                        let free_pct = free_kb * 100 / total_kb;
4186                        let msg = format!(
4187                            "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4188                        );
4189                        if free_pct < 10 {
4190                            watch.push(format!(
4191                                "{msg} — very low. Close unused apps to free up memory."
4192                            ));
4193                        } else if free_pct < 25 {
4194                            watch.push(format!("{msg} — running a bit low."));
4195                        } else {
4196                            good.push(msg);
4197                        }
4198                        return;
4199                    }
4200                }
4201            }
4202        }
4203    }
4204
4205    #[cfg(not(target_os = "windows"))]
4206    {
4207        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4208            let mut total_kb = 0u64;
4209            let mut avail_kb = 0u64;
4210            for line in content.lines() {
4211                if line.starts_with("MemTotal:") {
4212                    total_kb = line
4213                        .split_whitespace()
4214                        .nth(1)
4215                        .and_then(|v| v.parse().ok())
4216                        .unwrap_or(0);
4217                } else if line.starts_with("MemAvailable:") {
4218                    avail_kb = line
4219                        .split_whitespace()
4220                        .nth(1)
4221                        .and_then(|v| v.parse().ok())
4222                        .unwrap_or(0);
4223                }
4224            }
4225            if total_kb > 0 {
4226                let free_gb = avail_kb / 1_048_576;
4227                let total_gb = total_kb / 1_048_576;
4228                let free_pct = avail_kb * 100 / total_kb;
4229                let msg =
4230                    format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4231                if free_pct < 10 {
4232                    watch.push(format!("{msg} — very low. Close unused apps."));
4233                } else if free_pct < 25 {
4234                    watch.push(format!("{msg} — running a bit low."));
4235                } else {
4236                    good.push(msg);
4237                }
4238            }
4239        }
4240    }
4241}
4242
4243fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4244    let tool_checks: &[(&str, &str, &str)] = &[
4245        ("git", "--version", "Git"),
4246        ("cargo", "--version", "Rust / Cargo"),
4247        ("node", "--version", "Node.js"),
4248        ("python", "--version", "Python"),
4249        ("python3", "--version", "Python 3"),
4250        ("npm", "--version", "npm"),
4251    ];
4252
4253    let mut found: Vec<String> = Vec::new();
4254    let mut missing: Vec<String> = Vec::new();
4255    let mut python_found = false;
4256
4257    for (cmd, arg, label) in tool_checks {
4258        if cmd.starts_with("python") && python_found {
4259            continue;
4260        }
4261        let ok = Command::new(cmd)
4262            .arg(arg)
4263            .stdout(std::process::Stdio::null())
4264            .stderr(std::process::Stdio::null())
4265            .status()
4266            .map(|s| s.success())
4267            .unwrap_or(false);
4268        if ok {
4269            found.push((*label).to_string());
4270            if cmd.starts_with("python") {
4271                python_found = true;
4272            }
4273        } else if !cmd.starts_with("python") || !python_found {
4274            missing.push((*label).to_string());
4275        }
4276    }
4277
4278    if !found.is_empty() {
4279        good.push(format!("Dev tools found: {}", found.join(", ")));
4280    }
4281    if !missing.is_empty() {
4282        watch.push(format!(
4283            "Not installed (or not on PATH): {} — only matters if you need them",
4284            missing.join(", ")
4285        ));
4286        tips.push(
4287            "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4288                .to_string(),
4289        );
4290    }
4291}
4292
4293fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4294    #[cfg(target_os = "windows")]
4295    {
4296        let script = r#"try {
4297    $cutoff = (Get-Date).AddHours(-24)
4298    $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4299    $count
4300} catch { "0" }"#;
4301        if let Ok(out) = Command::new("powershell")
4302            .args(["-NoProfile", "-Command", script])
4303            .output()
4304        {
4305            let text = String::from_utf8_lossy(&out.stdout);
4306            let count: u64 = text.trim().parse().unwrap_or(0);
4307            if count > 0 {
4308                watch.push(format!(
4309                    "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4310                    if count == 1 { "" } else { "s" }
4311                ));
4312                tips.push(
4313                    "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4314                        .to_string(),
4315                );
4316            }
4317        }
4318    }
4319
4320    #[cfg(not(target_os = "windows"))]
4321    {
4322        if let Ok(out) = Command::new("journalctl")
4323            .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4324            .output()
4325        {
4326            let text = String::from_utf8_lossy(&out.stdout);
4327            if !text.trim().is_empty() {
4328                watch.push("Critical/error entries found in the system journal.".to_string());
4329                tips.push(
4330                    "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4331                );
4332            }
4333        }
4334    }
4335}
4336
4337// ── log_check ─────────────────────────────────────────────────────────────────
4338
4339fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4340    let mut out = String::from("Host inspection: log_check\n\n");
4341
4342    #[cfg(target_os = "windows")]
4343    {
4344        // Pull recent critical/error events from Windows Application and System logs.
4345        let hours = lookback_hours.unwrap_or(24);
4346        out.push_str(&format!(
4347            "Checking System/Application logs from the last {} hours...\n\n",
4348            hours
4349        ));
4350
4351        let n = max_entries.clamp(1, 50);
4352        let script = format!(
4353            r#"try {{
4354    $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4355    if (-not $events) {{ "NO_EVENTS"; exit }}
4356    $events | Select-Object -First {n} | ForEach-Object {{
4357        $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4358        $line
4359    }}
4360}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4361            hours = hours,
4362            n = n
4363        );
4364        let output = Command::new("powershell")
4365            .args(["-NoProfile", "-Command", &script])
4366            .output()
4367            .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4368
4369        let raw = String::from_utf8_lossy(&output.stdout);
4370        let text = raw.trim();
4371
4372        if text.is_empty() || text == "NO_EVENTS" {
4373            out.push_str("No critical or error events found in Application/System logs.\n");
4374            return Ok(out.trim_end().to_string());
4375        }
4376        if text.starts_with("ERROR:") {
4377            out.push_str(&format!("Warning: event log query returned: {text}\n"));
4378            return Ok(out.trim_end().to_string());
4379        }
4380
4381        let mut count = 0usize;
4382        for line in text.lines() {
4383            let parts: Vec<&str> = line.splitn(4, '|').collect();
4384            if parts.len() == 4 {
4385                let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4386                out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4387                count += 1;
4388            }
4389        }
4390        out.push_str(&format!(
4391            "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4392        ));
4393    }
4394
4395    #[cfg(not(target_os = "windows"))]
4396    {
4397        let _ = lookback_hours;
4398        // Use journalctl on Linux/macOS if available.
4399        let n = max_entries.clamp(1, 50).to_string();
4400        let output = Command::new("journalctl")
4401            .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4402            .output();
4403
4404        match output {
4405            Ok(o) if o.status.success() => {
4406                let text = String::from_utf8_lossy(&o.stdout);
4407                let trimmed = text.trim();
4408                if trimmed.is_empty() || trimmed.contains("No entries") {
4409                    out.push_str("No critical or error entries found in the system journal.\n");
4410                } else {
4411                    out.push_str(trimmed);
4412                    out.push('\n');
4413                    out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4414                }
4415            }
4416            _ => {
4417                // Fallback: check /var/log/syslog or /var/log/messages
4418                let log_paths = ["/var/log/syslog", "/var/log/messages"];
4419                let mut found = false;
4420                for log_path in &log_paths {
4421                    if let Ok(content) = std::fs::read_to_string(log_path) {
4422                        let lines: Vec<&str> = content.lines().collect();
4423                        let tail: Vec<&str> = lines
4424                            .iter()
4425                            .rev()
4426                            .filter(|l| {
4427                                let l_lower = l.to_ascii_lowercase();
4428                                l_lower.contains("error") || l_lower.contains("crit")
4429                            })
4430                            .take(max_entries)
4431                            .copied()
4432                            .collect::<Vec<_>>()
4433                            .into_iter()
4434                            .rev()
4435                            .collect();
4436                        if !tail.is_empty() {
4437                            out.push_str(&format!("Source: {log_path}\n"));
4438                            for l in &tail {
4439                                out.push_str(l);
4440                                out.push('\n');
4441                            }
4442                            found = true;
4443                            break;
4444                        }
4445                    }
4446                }
4447                if !found {
4448                    out.push_str(
4449                        "journalctl not found and no readable syslog detected on this system.\n",
4450                    );
4451                }
4452            }
4453        }
4454    }
4455
4456    Ok(out.trim_end().to_string())
4457}
4458
4459// ── startup_items ─────────────────────────────────────────────────────────────
4460
4461fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4462    let mut out = String::from("Host inspection: startup_items\n\n");
4463
4464    #[cfg(target_os = "windows")]
4465    {
4466        // Query both HKLM and HKCU Run keys.
4467        let script = r#"
4468$hives = @(
4469    @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4470    @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4471    @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4472)
4473foreach ($h in $hives) {
4474    try {
4475        $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4476        $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4477            "$($h.Hive)|$($_.Name)|$($_.Value)"
4478        }
4479    } catch {}
4480}
4481"#;
4482        let output = Command::new("powershell")
4483            .args(["-NoProfile", "-Command", script])
4484            .output()
4485            .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4486
4487        let raw = String::from_utf8_lossy(&output.stdout);
4488        let text = raw.trim();
4489
4490        let entries: Vec<(String, String, String)> = text
4491            .lines()
4492            .filter_map(|l| {
4493                let parts: Vec<&str> = l.splitn(3, '|').collect();
4494                if parts.len() == 3 {
4495                    Some((
4496                        parts[0].to_string(),
4497                        parts[1].to_string(),
4498                        parts[2].to_string(),
4499                    ))
4500                } else {
4501                    None
4502                }
4503            })
4504            .take(max_entries)
4505            .collect();
4506
4507        if entries.is_empty() {
4508            out.push_str("No startup entries found in the Windows Run registry keys.\n");
4509        } else {
4510            out.push_str("Registry run keys (programs that start with Windows):\n\n");
4511            let mut last_hive = String::new();
4512            for (hive, name, value) in &entries {
4513                if *hive != last_hive {
4514                    out.push_str(&format!("[{}]\n", hive));
4515                    last_hive = hive.clone();
4516                }
4517                // Truncate very long values (paths with many args)
4518                let display = if value.len() > 100 {
4519                    format!("{}…", &value[..100])
4520                } else {
4521                    value.clone()
4522                };
4523                out.push_str(&format!("  {name}: {display}\n"));
4524            }
4525            out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4526        }
4527
4528        // 3. Unified Startup Command check (Task Manager style)
4529        let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { "  $($_.Name): $($_.Command) ($($_.Location))" }"#;
4530        if let Ok(unified_out) = Command::new("powershell")
4531            .args(["-NoProfile", "-Command", unified_script])
4532            .output()
4533        {
4534            let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4535            let trimmed = unified_text.trim();
4536            if !trimmed.is_empty() {
4537                out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4538                out.push_str(trimmed);
4539                out.push('\n');
4540            }
4541        }
4542    }
4543
4544    #[cfg(not(target_os = "windows"))]
4545    {
4546        // On Linux: systemd enabled services + cron @reboot entries.
4547        let output = Command::new("systemctl")
4548            .args([
4549                "list-unit-files",
4550                "--type=service",
4551                "--state=enabled",
4552                "--no-legend",
4553                "--no-pager",
4554                "--plain",
4555            ])
4556            .output();
4557
4558        match output {
4559            Ok(o) if o.status.success() => {
4560                let text = String::from_utf8_lossy(&o.stdout);
4561                let services: Vec<&str> = text
4562                    .lines()
4563                    .filter(|l| !l.trim().is_empty())
4564                    .take(max_entries)
4565                    .collect();
4566                if services.is_empty() {
4567                    out.push_str("No enabled systemd services found.\n");
4568                } else {
4569                    out.push_str("Enabled systemd services (run at boot):\n\n");
4570                    for s in &services {
4571                        out.push_str(&format!("  {s}\n"));
4572                    }
4573                    out.push_str(&format!(
4574                        "\nShowing {} of enabled services.\n",
4575                        services.len()
4576                    ));
4577                }
4578            }
4579            _ => {
4580                out.push_str(
4581                    "systemctl not found on this system. Cannot enumerate startup services.\n",
4582                );
4583            }
4584        }
4585
4586        // Check @reboot cron entries.
4587        if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4588            let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4589            let reboot_entries: Vec<&str> = cron_text
4590                .lines()
4591                .filter(|l| l.trim_start().starts_with("@reboot"))
4592                .collect();
4593            if !reboot_entries.is_empty() {
4594                out.push_str("\nCron @reboot entries:\n");
4595                for e in reboot_entries {
4596                    out.push_str(&format!("  {e}\n"));
4597                }
4598            }
4599        }
4600    }
4601
4602    Ok(out.trim_end().to_string())
4603}
4604
4605fn inspect_os_config() -> Result<String, String> {
4606    let mut out = String::from("Host inspection: OS Configuration\n\n");
4607
4608    #[cfg(target_os = "windows")]
4609    {
4610        // Power Plan
4611        if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4612            let power_str = String::from_utf8_lossy(&power_out.stdout);
4613            out.push_str("=== Power Plan ===\n");
4614            out.push_str(power_str.trim());
4615            out.push_str("\n\n");
4616        }
4617
4618        // Firewall Status
4619        let fw_script =
4620            "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4621        if let Ok(fw_out) = Command::new("powershell")
4622            .args(["-NoProfile", "-Command", fw_script])
4623            .output()
4624        {
4625            let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4626            out.push_str("=== Firewall Profiles ===\n");
4627            out.push_str(fw_str.trim());
4628            out.push_str("\n\n");
4629        }
4630
4631        // System Uptime
4632        let uptime_script =
4633            "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4634        if let Ok(uptime_out) = Command::new("powershell")
4635            .args(["-NoProfile", "-Command", uptime_script])
4636            .output()
4637        {
4638            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4639            out.push_str("=== System Uptime (Last Boot) ===\n");
4640            out.push_str(uptime_str.trim());
4641            out.push_str("\n\n");
4642        }
4643    }
4644
4645    #[cfg(not(target_os = "windows"))]
4646    {
4647        // Uptime
4648        if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4649            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4650            out.push_str("=== System Uptime ===\n");
4651            out.push_str(uptime_str.trim());
4652            out.push_str("\n\n");
4653        }
4654
4655        // Firewall (ufw status if available)
4656        if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4657            let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4658            if !ufw_str.trim().is_empty() {
4659                out.push_str("=== Firewall (UFW) ===\n");
4660                out.push_str(ufw_str.trim());
4661                out.push_str("\n\n");
4662            }
4663        }
4664    }
4665    Ok(out.trim_end().to_string())
4666}
4667
4668pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
4669    let action = args
4670        .get("action")
4671        .and_then(|v| v.as_str())
4672        .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
4673
4674    let target = args
4675        .get("target")
4676        .and_then(|v| v.as_str())
4677        .unwrap_or("")
4678        .trim();
4679
4680    if target.is_empty() && action != "clear_temp" {
4681        return Err("Missing required argument: 'target' for this action".to_string());
4682    }
4683
4684    match action {
4685        "install_package" => {
4686            #[cfg(target_os = "windows")]
4687            {
4688                let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
4689                match Command::new("powershell")
4690                    .args(["-NoProfile", "-Command", &cmd])
4691                    .output()
4692                {
4693                    Ok(out) => Ok(format!(
4694                        "Executed remediation (winget install):\n{}",
4695                        String::from_utf8_lossy(&out.stdout)
4696                    )),
4697                    Err(e) => Err(format!("Failed to run winget: {}", e)),
4698                }
4699            }
4700            #[cfg(not(target_os = "windows"))]
4701            {
4702                Err(
4703                    "install_package via wrapper is only supported on Windows currently (winget)"
4704                        .to_string(),
4705                )
4706            }
4707        }
4708        "restart_service" => {
4709            #[cfg(target_os = "windows")]
4710            {
4711                let cmd = format!("Restart-Service -Name {} -Force", target);
4712                match Command::new("powershell")
4713                    .args(["-NoProfile", "-Command", &cmd])
4714                    .output()
4715                {
4716                    Ok(out) => {
4717                        let err_str = String::from_utf8_lossy(&out.stderr);
4718                        if !err_str.is_empty() {
4719                            return Err(format!("Error restarting service:\n{}", err_str));
4720                        }
4721                        Ok(format!("Successfully restarted service: {}", target))
4722                    }
4723                    Err(e) => Err(format!("Failed to restart service: {}", e)),
4724                }
4725            }
4726            #[cfg(not(target_os = "windows"))]
4727            {
4728                Err(
4729                    "restart_service via wrapper is only supported on Windows currently"
4730                        .to_string(),
4731                )
4732            }
4733        }
4734        "clear_temp" => {
4735            #[cfg(target_os = "windows")]
4736            {
4737                let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
4738                match Command::new("powershell")
4739                    .args(["-NoProfile", "-Command", cmd])
4740                    .output()
4741                {
4742                    Ok(_) => Ok("Successfully cleared temporary files".to_string()),
4743                    Err(e) => Err(format!("Failed to clear temp: {}", e)),
4744                }
4745            }
4746            #[cfg(not(target_os = "windows"))]
4747            {
4748                Err("clear_temp via wrapper is only supported on Windows currently".to_string())
4749            }
4750        }
4751        other => Err(format!("Unknown remediation action: {}", other)),
4752    }
4753}
4754
4755// ── storage ───────────────────────────────────────────────────────────────────
4756
4757fn inspect_storage(max_entries: usize) -> Result<String, String> {
4758    let mut out = String::from("Host inspection: storage\n\n");
4759    let _ = max_entries; // used by non-Windows branch
4760
4761    // ── Drive overview ────────────────────────────────────────────────────────
4762    out.push_str("Drives:\n");
4763
4764    #[cfg(target_os = "windows")]
4765    {
4766        let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
4767    $free = $_.Free
4768    $used = $_.Used
4769    if ($free -eq $null) { $free = 0 }
4770    if ($used -eq $null) { $used = 0 }
4771    $total = $free + $used
4772    "$($_.Name)|$free|$used|$total"
4773}"#;
4774        match Command::new("powershell")
4775            .args(["-NoProfile", "-Command", script])
4776            .output()
4777        {
4778            Ok(o) => {
4779                let text = String::from_utf8_lossy(&o.stdout);
4780                let mut drive_count = 0usize;
4781                for line in text.lines() {
4782                    let parts: Vec<&str> = line.trim().split('|').collect();
4783                    if parts.len() == 4 {
4784                        let name = parts[0];
4785                        let free: u64 = parts[1].parse().unwrap_or(0);
4786                        let total: u64 = parts[3].parse().unwrap_or(0);
4787                        if total == 0 {
4788                            continue;
4789                        }
4790                        let free_gb = free / 1_073_741_824;
4791                        let total_gb = total / 1_073_741_824;
4792                        let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
4793                        let bar_len = 20usize;
4794                        let filled = (used_pct as usize * bar_len / 100).min(bar_len);
4795                        let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
4796                        let warn = if free_gb < 5 {
4797                            " [!] CRITICALLY LOW"
4798                        } else if free_gb < 15 {
4799                            " [-] LOW"
4800                        } else {
4801                            ""
4802                        };
4803                        out.push_str(&format!(
4804                            "  {name}:  [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
4805                        ));
4806                        drive_count += 1;
4807                    }
4808                }
4809                if drive_count == 0 {
4810                    out.push_str("  (could not enumerate drives)\n");
4811                }
4812            }
4813            Err(e) => out.push_str(&format!("  (drive scan failed: {e})\n")),
4814        }
4815
4816        // ── Real-time Performance (Latency) ──────────────────────────────────
4817        let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
4818        match Command::new("powershell")
4819            .args(["-NoProfile", "-Command", latency_script])
4820            .output()
4821        {
4822            Ok(o) => {
4823                out.push_str("\nReal-time Disk Intensity:\n");
4824                let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
4825                if !text.is_empty() {
4826                    out.push_str(&format!("  Average Disk Queue Length: {text}\n"));
4827                    if let Ok(q) = text.parse::<f64>() {
4828                        if q > 2.0 {
4829                            out.push_str(
4830                                "  [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
4831                            );
4832                        } else {
4833                            out.push_str("  [~] Disk latency is within healthy bounds.\n");
4834                        }
4835                    }
4836                } else {
4837                    out.push_str("  Average Disk Queue Length: unavailable\n");
4838                }
4839            }
4840            Err(_) => {
4841                out.push_str("\nReal-time Disk Intensity:\n");
4842                out.push_str("  Average Disk Queue Length: unavailable\n");
4843            }
4844        }
4845    }
4846
4847    #[cfg(not(target_os = "windows"))]
4848    {
4849        match Command::new("df")
4850            .args(["-h", "--output=target,size,avail,pcent"])
4851            .output()
4852        {
4853            Ok(o) => {
4854                let text = String::from_utf8_lossy(&o.stdout);
4855                let mut count = 0usize;
4856                for line in text.lines().skip(1) {
4857                    let cols: Vec<&str> = line.split_whitespace().collect();
4858                    if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
4859                        out.push_str(&format!(
4860                            "  {}  size: {}  avail: {}  used: {}\n",
4861                            cols[0], cols[1], cols[2], cols[3]
4862                        ));
4863                        count += 1;
4864                        if count >= max_entries {
4865                            break;
4866                        }
4867                    }
4868                }
4869            }
4870            Err(e) => out.push_str(&format!("  (df failed: {e})\n")),
4871        }
4872    }
4873
4874    // ── Large developer cache directories ─────────────────────────────────────
4875    out.push_str("\nLarge developer cache directories (if present):\n");
4876
4877    #[cfg(target_os = "windows")]
4878    {
4879        let home = std::env::var("USERPROFILE").unwrap_or_default();
4880        let check_dirs: &[(&str, &str)] = &[
4881            ("Temp", r"AppData\Local\Temp"),
4882            ("npm cache", r"AppData\Roaming\npm-cache"),
4883            ("Cargo registry", r".cargo\registry"),
4884            ("Cargo git", r".cargo\git"),
4885            ("pip cache", r"AppData\Local\pip\cache"),
4886            ("Yarn cache", r"AppData\Local\Yarn\Cache"),
4887            (".rustup toolchains", r".rustup\toolchains"),
4888            ("node_modules (home)", r"node_modules"),
4889        ];
4890
4891        let mut found_any = false;
4892        for (label, rel) in check_dirs {
4893            let full = format!(r"{}\{}", home, rel);
4894            let path = std::path::Path::new(&full);
4895            if path.exists() {
4896                // Quick size estimate via PowerShell (non-blocking cap at 5s)
4897                let size_script = format!(
4898                    r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
4899                    full.replace('\'', "''")
4900                );
4901                let size_mb = Command::new("powershell")
4902                    .args(["-NoProfile", "-Command", &size_script])
4903                    .output()
4904                    .ok()
4905                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
4906                    .unwrap_or_else(|| "?".to_string());
4907                out.push_str(&format!("  {label}: {size_mb} MB  ({full})\n"));
4908                found_any = true;
4909            }
4910        }
4911        if !found_any {
4912            out.push_str("  (none of the common cache directories found)\n");
4913        }
4914
4915        out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
4916    }
4917
4918    #[cfg(not(target_os = "windows"))]
4919    {
4920        let home = std::env::var("HOME").unwrap_or_default();
4921        let check_dirs: &[(&str, &str)] = &[
4922            ("npm cache", ".npm"),
4923            ("Cargo registry", ".cargo/registry"),
4924            ("pip cache", ".cache/pip"),
4925            (".rustup toolchains", ".rustup/toolchains"),
4926            ("Yarn cache", ".cache/yarn"),
4927        ];
4928        let mut found_any = false;
4929        for (label, rel) in check_dirs {
4930            let full = format!("{}/{}", home, rel);
4931            if std::path::Path::new(&full).exists() {
4932                let size = Command::new("du")
4933                    .args(["-sh", &full])
4934                    .output()
4935                    .ok()
4936                    .map(|o| {
4937                        let s = String::from_utf8_lossy(&o.stdout);
4938                        s.split_whitespace().next().unwrap_or("?").to_string()
4939                    })
4940                    .unwrap_or_else(|| "?".to_string());
4941                out.push_str(&format!("  {label}: {size}  ({full})\n"));
4942                found_any = true;
4943            }
4944        }
4945        if !found_any {
4946            out.push_str("  (none of the common cache directories found)\n");
4947        }
4948    }
4949
4950    Ok(out.trim_end().to_string())
4951}
4952
4953// ── hardware ──────────────────────────────────────────────────────────────────
4954
4955fn inspect_hardware() -> Result<String, String> {
4956    let mut out = String::from("Host inspection: hardware\n\n");
4957
4958    #[cfg(target_os = "windows")]
4959    {
4960        // CPU
4961        let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
4962    "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
4963} | Select-Object -First 1"#;
4964        if let Ok(o) = Command::new("powershell")
4965            .args(["-NoProfile", "-Command", cpu_script])
4966            .output()
4967        {
4968            let text = String::from_utf8_lossy(&o.stdout);
4969            let text = text.trim();
4970            let parts: Vec<&str> = text.split('|').collect();
4971            if parts.len() == 4 {
4972                out.push_str(&format!(
4973                    "CPU: {}\n  {} physical cores, {} logical processors, {:.1} GHz\n\n",
4974                    parts[0],
4975                    parts[1],
4976                    parts[2],
4977                    parts[3].parse::<f32>().unwrap_or(0.0)
4978                ));
4979            } else {
4980                out.push_str(&format!("CPU: {text}\n\n"));
4981            }
4982        }
4983
4984        // RAM (total installed + speed)
4985        let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
4986$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
4987$speed = ($sticks | Select-Object -First 1).Speed
4988"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
4989        if let Ok(o) = Command::new("powershell")
4990            .args(["-NoProfile", "-Command", ram_script])
4991            .output()
4992        {
4993            let text = String::from_utf8_lossy(&o.stdout);
4994            out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
4995        }
4996
4997        // GPU(s)
4998        let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
4999    "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
5000}"#;
5001        if let Ok(o) = Command::new("powershell")
5002            .args(["-NoProfile", "-Command", gpu_script])
5003            .output()
5004        {
5005            let text = String::from_utf8_lossy(&o.stdout);
5006            let lines: Vec<&str> = text.lines().collect();
5007            if !lines.is_empty() {
5008                out.push_str("GPU(s):\n");
5009                for line in lines.iter().filter(|l| !l.trim().is_empty()) {
5010                    let parts: Vec<&str> = line.trim().split('|').collect();
5011                    if parts.len() == 3 {
5012                        let res = if parts[2] == "x" || parts[2].starts_with('0') {
5013                            String::new()
5014                        } else {
5015                            format!(" — {}@display", parts[2])
5016                        };
5017                        out.push_str(&format!(
5018                            "  {}\n    Driver: {}{}\n",
5019                            parts[0], parts[1], res
5020                        ));
5021                    } else {
5022                        out.push_str(&format!("  {}\n", line.trim()));
5023                    }
5024                }
5025                out.push('\n');
5026            }
5027        }
5028
5029        // Motherboard + BIOS + Virtualization
5030        let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
5031$bios = Get-CimInstance Win32_BIOS
5032$cs = Get-CimInstance Win32_ComputerSystem
5033$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
5034$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
5035"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
5036        if let Ok(o) = Command::new("powershell")
5037            .args(["-NoProfile", "-Command", mb_script])
5038            .output()
5039        {
5040            let text = String::from_utf8_lossy(&o.stdout);
5041            let text = text.trim().trim_matches('"');
5042            let parts: Vec<&str> = text.split('|').collect();
5043            if parts.len() == 4 {
5044                out.push_str(&format!(
5045                    "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
5046                    parts[0].trim(),
5047                    parts[1].trim(),
5048                    parts[2].trim(),
5049                    parts[3].trim()
5050                ));
5051            }
5052        }
5053
5054        // Display(s)
5055        let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
5056    "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
5057}"#;
5058        if let Ok(o) = Command::new("powershell")
5059            .args(["-NoProfile", "-Command", disp_script])
5060            .output()
5061        {
5062            let text = String::from_utf8_lossy(&o.stdout);
5063            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
5064            if !lines.is_empty() {
5065                out.push_str("Display(s):\n");
5066                for line in &lines {
5067                    let parts: Vec<&str> = line.trim().split('|').collect();
5068                    if parts.len() == 2 {
5069                        out.push_str(&format!("  {} — {}\n", parts[0].trim(), parts[1]));
5070                    }
5071                }
5072            }
5073        }
5074    }
5075
5076    #[cfg(not(target_os = "windows"))]
5077    {
5078        // CPU via /proc/cpuinfo
5079        if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
5080            let model = content
5081                .lines()
5082                .find(|l| l.starts_with("model name"))
5083                .and_then(|l| l.split(':').nth(1))
5084                .map(str::trim)
5085                .unwrap_or("unknown");
5086            let cores = content
5087                .lines()
5088                .filter(|l| l.starts_with("processor"))
5089                .count();
5090            out.push_str(&format!("CPU: {model}\n  {cores} logical processors\n\n"));
5091        }
5092
5093        // RAM
5094        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
5095            let total_kb: u64 = content
5096                .lines()
5097                .find(|l| l.starts_with("MemTotal:"))
5098                .and_then(|l| l.split_whitespace().nth(1))
5099                .and_then(|v| v.parse().ok())
5100                .unwrap_or(0);
5101            let total_gb = total_kb / 1_048_576;
5102            out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
5103        }
5104
5105        // GPU via lspci
5106        if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
5107            let text = String::from_utf8_lossy(&o.stdout);
5108            let gpu_lines: Vec<&str> = text
5109                .lines()
5110                .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
5111                .collect();
5112            if !gpu_lines.is_empty() {
5113                out.push_str("GPU(s):\n");
5114                for l in gpu_lines {
5115                    out.push_str(&format!("  {l}\n"));
5116                }
5117                out.push('\n');
5118            }
5119        }
5120
5121        // DMI/BIOS info
5122        if let Ok(o) = Command::new("dmidecode")
5123            .args(["-t", "baseboard", "-t", "bios"])
5124            .output()
5125        {
5126            let text = String::from_utf8_lossy(&o.stdout);
5127            out.push_str("Motherboard/BIOS:\n");
5128            for line in text
5129                .lines()
5130                .filter(|l| {
5131                    l.contains("Manufacturer:")
5132                        || l.contains("Product Name:")
5133                        || l.contains("Version:")
5134                })
5135                .take(6)
5136            {
5137                out.push_str(&format!("  {}\n", line.trim()));
5138            }
5139        }
5140    }
5141
5142    Ok(out.trim_end().to_string())
5143}
5144
5145// ── updates ───────────────────────────────────────────────────────────────────
5146
5147fn inspect_updates() -> Result<String, String> {
5148    let mut out = String::from("Host inspection: updates\n\n");
5149
5150    #[cfg(target_os = "windows")]
5151    {
5152        // Last installed update via COM
5153        let script = r#"
5154try {
5155    $sess = New-Object -ComObject Microsoft.Update.Session
5156    $searcher = $sess.CreateUpdateSearcher()
5157    $count = $searcher.GetTotalHistoryCount()
5158    if ($count -gt 0) {
5159        $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
5160        $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5161    } else { "NONE|LAST_INSTALL" }
5162} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5163"#;
5164        if let Ok(o) = Command::new("powershell")
5165            .args(["-NoProfile", "-Command", script])
5166            .output()
5167        {
5168            let raw = String::from_utf8_lossy(&o.stdout);
5169            let text = raw.trim();
5170            if text.starts_with("ERROR:") {
5171                out.push_str("Last update install: (unable to query)\n");
5172            } else if text.contains("NONE") {
5173                out.push_str("Last update install: No update history found\n");
5174            } else {
5175                let date = text.replace("|LAST_INSTALL", "");
5176                out.push_str(&format!("Last update install: {date}\n"));
5177            }
5178        }
5179
5180        // Pending updates count
5181        let pending_script = r#"
5182try {
5183    $sess = New-Object -ComObject Microsoft.Update.Session
5184    $searcher = $sess.CreateUpdateSearcher()
5185    $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5186    $results.Updates.Count.ToString() + "|PENDING"
5187} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5188"#;
5189        if let Ok(o) = Command::new("powershell")
5190            .args(["-NoProfile", "-Command", pending_script])
5191            .output()
5192        {
5193            let raw = String::from_utf8_lossy(&o.stdout);
5194            let text = raw.trim();
5195            if text.starts_with("ERROR:") {
5196                out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5197            } else {
5198                let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5199                if count == 0 {
5200                    out.push_str("Pending updates: Up to date — no updates waiting\n");
5201                } else if count > 0 {
5202                    out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5203                    out.push_str(
5204                        "  → Open Windows Update (Settings > Windows Update) to install\n",
5205                    );
5206                }
5207            }
5208        }
5209
5210        // Windows Update service state
5211        let svc_script = r#"
5212$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5213if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5214"#;
5215        if let Ok(o) = Command::new("powershell")
5216            .args(["-NoProfile", "-Command", svc_script])
5217            .output()
5218        {
5219            let raw = String::from_utf8_lossy(&o.stdout);
5220            let status = raw.trim();
5221            out.push_str(&format!("Windows Update service: {status}\n"));
5222        }
5223    }
5224
5225    #[cfg(not(target_os = "windows"))]
5226    {
5227        let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5228        let mut found = false;
5229        if let Ok(o) = apt_out {
5230            let text = String::from_utf8_lossy(&o.stdout);
5231            let lines: Vec<&str> = text
5232                .lines()
5233                .filter(|l| l.contains('/') && !l.contains("Listing"))
5234                .collect();
5235            if !lines.is_empty() {
5236                out.push_str(&format!(
5237                    "{} package(s) can be upgraded (apt)\n",
5238                    lines.len()
5239                ));
5240                out.push_str("  → Run: sudo apt upgrade\n");
5241                found = true;
5242            }
5243        }
5244        if !found {
5245            if let Ok(o) = Command::new("dnf")
5246                .args(["check-update", "--quiet"])
5247                .output()
5248            {
5249                let text = String::from_utf8_lossy(&o.stdout);
5250                let count = text
5251                    .lines()
5252                    .filter(|l| !l.is_empty() && !l.starts_with('!'))
5253                    .count();
5254                if count > 0 {
5255                    out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5256                    out.push_str("  → Run: sudo dnf upgrade\n");
5257                } else {
5258                    out.push_str("System is up to date.\n");
5259                }
5260            } else {
5261                out.push_str("Could not query package manager for updates.\n");
5262            }
5263        }
5264    }
5265
5266    Ok(out.trim_end().to_string())
5267}
5268
5269// ── security ──────────────────────────────────────────────────────────────────
5270
5271fn inspect_security() -> Result<String, String> {
5272    let mut out = String::from("Host inspection: security\n\n");
5273
5274    #[cfg(target_os = "windows")]
5275    {
5276        // Windows Defender status
5277        let defender_script = r#"
5278try {
5279    $status = Get-MpComputerStatus -ErrorAction Stop
5280    "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5281} catch { "ERROR:" + $_.Exception.Message }
5282"#;
5283        if let Ok(o) = Command::new("powershell")
5284            .args(["-NoProfile", "-Command", defender_script])
5285            .output()
5286        {
5287            let raw = String::from_utf8_lossy(&o.stdout);
5288            let text = raw.trim();
5289            if text.starts_with("ERROR:") {
5290                out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5291            } else {
5292                let get = |key: &str| -> String {
5293                    text.split('|')
5294                        .find(|s| s.starts_with(key))
5295                        .and_then(|s| s.splitn(2, ':').nth(1))
5296                        .unwrap_or("unknown")
5297                        .to_string()
5298                };
5299                let rtp = get("RTP");
5300                let last_scan = {
5301                    // SCAN field has a colon in the time, so grab everything after "SCAN:"
5302                    text.split('|')
5303                        .find(|s| s.starts_with("SCAN:"))
5304                        .and_then(|s| s.get(5..))
5305                        .unwrap_or("unknown")
5306                        .to_string()
5307                };
5308                let def_ver = get("VER");
5309                let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5310
5311                let rtp_label = if rtp == "True" {
5312                    "ENABLED"
5313                } else {
5314                    "DISABLED [!]"
5315                };
5316                out.push_str(&format!(
5317                    "Windows Defender real-time protection: {rtp_label}\n"
5318                ));
5319                out.push_str(&format!("Last quick scan: {last_scan}\n"));
5320                out.push_str(&format!("Signature version: {def_ver}\n"));
5321                if age_days >= 0 {
5322                    let freshness = if age_days == 0 {
5323                        "up to date".to_string()
5324                    } else if age_days <= 3 {
5325                        format!("{age_days} day(s) old — OK")
5326                    } else if age_days <= 7 {
5327                        format!("{age_days} day(s) old — consider updating")
5328                    } else {
5329                        format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5330                    };
5331                    out.push_str(&format!("Signature age: {freshness}\n"));
5332                }
5333                if rtp != "True" {
5334                    out.push_str(
5335                        "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5336                    );
5337                    out.push_str(
5338                        "    → Open Windows Security > Virus & threat protection to re-enable.\n",
5339                    );
5340                }
5341            }
5342        }
5343
5344        out.push('\n');
5345
5346        // Windows Firewall state
5347        let fw_script = r#"
5348try {
5349    Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5350} catch { "ERROR:" + $_.Exception.Message }
5351"#;
5352        if let Ok(o) = Command::new("powershell")
5353            .args(["-NoProfile", "-Command", fw_script])
5354            .output()
5355        {
5356            let raw = String::from_utf8_lossy(&o.stdout);
5357            let text = raw.trim();
5358            if !text.starts_with("ERROR:") && !text.is_empty() {
5359                out.push_str("Windows Firewall:\n");
5360                for line in text.lines() {
5361                    if let Some((name, enabled)) = line.split_once(':') {
5362                        let state = if enabled.trim() == "True" {
5363                            "ON"
5364                        } else {
5365                            "OFF [!]"
5366                        };
5367                        out.push_str(&format!("  {name}: {state}\n"));
5368                    }
5369                }
5370                out.push('\n');
5371            }
5372        }
5373
5374        // Windows activation status
5375        let act_script = r#"
5376try {
5377    $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5378    if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5379} catch { "UNKNOWN" }
5380"#;
5381        if let Ok(o) = Command::new("powershell")
5382            .args(["-NoProfile", "-Command", act_script])
5383            .output()
5384        {
5385            let raw = String::from_utf8_lossy(&o.stdout);
5386            match raw.trim() {
5387                "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5388                "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5389                _ => out.push_str("Windows activation: Unable to determine\n"),
5390            }
5391        }
5392
5393        // UAC state
5394        let uac_script = r#"
5395$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5396if ($val -eq 1) { "ON" } else { "OFF" }
5397"#;
5398        if let Ok(o) = Command::new("powershell")
5399            .args(["-NoProfile", "-Command", uac_script])
5400            .output()
5401        {
5402            let raw = String::from_utf8_lossy(&o.stdout);
5403            let state = raw.trim();
5404            let label = if state == "ON" {
5405                "Enabled"
5406            } else {
5407                "DISABLED [!] — recommended to re-enable via secpol.msc"
5408            };
5409            out.push_str(&format!("UAC (User Account Control): {label}\n"));
5410        }
5411    }
5412
5413    #[cfg(not(target_os = "windows"))]
5414    {
5415        if let Ok(o) = Command::new("ufw").arg("status").output() {
5416            let text = String::from_utf8_lossy(&o.stdout);
5417            out.push_str(&format!(
5418                "UFW: {}\n",
5419                text.lines().next().unwrap_or("unknown")
5420            ));
5421        }
5422        if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5423            if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5424                out.push_str(&format!("{line}\n"));
5425            }
5426        }
5427    }
5428
5429    Ok(out.trim_end().to_string())
5430}
5431
5432// ── pending_reboot ────────────────────────────────────────────────────────────
5433
5434fn inspect_pending_reboot() -> Result<String, String> {
5435    let mut out = String::from("Host inspection: pending_reboot\n\n");
5436
5437    #[cfg(target_os = "windows")]
5438    {
5439        let script = r#"
5440$reasons = @()
5441if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5442    $reasons += "Windows Update requires a restart"
5443}
5444if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5445    $reasons += "Windows component install/update requires a restart"
5446}
5447$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5448if ($pfro -and $pfro.PendingFileRenameOperations) {
5449    $reasons += "Pending file rename operations (driver or system file replacement)"
5450}
5451if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5452"#;
5453        let output = Command::new("powershell")
5454            .args(["-NoProfile", "-Command", script])
5455            .output()
5456            .map_err(|e| format!("pending_reboot: {e}"))?;
5457
5458        let raw = String::from_utf8_lossy(&output.stdout);
5459        let text = raw.trim();
5460
5461        if text == "NO_REBOOT_NEEDED" {
5462            out.push_str("No restart required — system is up to date and stable.\n");
5463        } else if text.is_empty() {
5464            out.push_str("Could not determine reboot status.\n");
5465        } else {
5466            out.push_str("[!] A system restart is pending:\n\n");
5467            for reason in text.split("|REASON|") {
5468                out.push_str(&format!("  • {}\n", reason.trim()));
5469            }
5470            out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5471        }
5472    }
5473
5474    #[cfg(not(target_os = "windows"))]
5475    {
5476        if std::path::Path::new("/var/run/reboot-required").exists() {
5477            out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5478            if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5479                out.push_str("Packages requiring restart:\n");
5480                for p in pkgs.lines().take(10) {
5481                    out.push_str(&format!("  • {p}\n"));
5482                }
5483            }
5484        } else {
5485            out.push_str("No restart required.\n");
5486        }
5487    }
5488
5489    Ok(out.trim_end().to_string())
5490}
5491
5492// ── disk_health ───────────────────────────────────────────────────────────────
5493
5494fn inspect_disk_health() -> Result<String, String> {
5495    let mut out = String::from("Host inspection: disk_health\n\n");
5496
5497    #[cfg(target_os = "windows")]
5498    {
5499        let script = r#"
5500try {
5501    $disks = Get-PhysicalDisk -ErrorAction Stop
5502    foreach ($d in $disks) {
5503        $size_gb = [math]::Round($d.Size / 1GB, 0)
5504        $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5505    }
5506} catch { "ERROR:" + $_.Exception.Message }
5507"#;
5508        let output = Command::new("powershell")
5509            .args(["-NoProfile", "-Command", script])
5510            .output()
5511            .map_err(|e| format!("disk_health: {e}"))?;
5512
5513        let raw = String::from_utf8_lossy(&output.stdout);
5514        let text = raw.trim();
5515
5516        if text.starts_with("ERROR:") {
5517            out.push_str(&format!("Unable to query disk health: {text}\n"));
5518            out.push_str("This may require running as administrator.\n");
5519        } else if text.is_empty() {
5520            out.push_str("No physical disks found.\n");
5521        } else {
5522            out.push_str("Physical Drive Health:\n\n");
5523            for line in text.lines() {
5524                let parts: Vec<&str> = line.splitn(5, '|').collect();
5525                if parts.len() >= 4 {
5526                    let name = parts[0];
5527                    let media = parts[1];
5528                    let size = parts[2];
5529                    let health = parts[3];
5530                    let op_status = parts.get(4).unwrap_or(&"");
5531                    let health_label = match health.trim() {
5532                        "Healthy" => "OK",
5533                        "Warning" => "[!] WARNING",
5534                        "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5535                        other => other,
5536                    };
5537                    out.push_str(&format!("  {name}\n"));
5538                    out.push_str(&format!("    Type: {media} | Size: {size}\n"));
5539                    out.push_str(&format!("    Health: {health_label}\n"));
5540                    if !op_status.is_empty() {
5541                        out.push_str(&format!("    Status: {op_status}\n"));
5542                    }
5543                    out.push('\n');
5544                }
5545            }
5546        }
5547
5548        // SMART failure prediction (best-effort, may need admin)
5549        let smart_script = r#"
5550try {
5551    Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5552        ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5553} catch { "" }
5554"#;
5555        if let Ok(o) = Command::new("powershell")
5556            .args(["-NoProfile", "-Command", smart_script])
5557            .output()
5558        {
5559            let raw2 = String::from_utf8_lossy(&o.stdout);
5560            let text2 = raw2.trim();
5561            if !text2.is_empty() {
5562                let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5563                if failures.is_empty() {
5564                    out.push_str("SMART failure prediction: No failures predicted\n");
5565                } else {
5566                    out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5567                    for f in failures {
5568                        let name = f.split('|').next().unwrap_or(f);
5569                        out.push_str(&format!("  • {name}\n"));
5570                    }
5571                    out.push_str(
5572                        "\nBack up your data immediately and replace the failing drive.\n",
5573                    );
5574                }
5575            }
5576        }
5577    }
5578
5579    #[cfg(not(target_os = "windows"))]
5580    {
5581        if let Ok(o) = Command::new("lsblk")
5582            .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5583            .output()
5584        {
5585            let text = String::from_utf8_lossy(&o.stdout);
5586            out.push_str("Block devices:\n");
5587            out.push_str(text.trim());
5588            out.push('\n');
5589        }
5590        if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5591            let devices = String::from_utf8_lossy(&scan.stdout);
5592            for dev_line in devices.lines().take(4) {
5593                let dev = dev_line.split_whitespace().next().unwrap_or("");
5594                if dev.is_empty() {
5595                    continue;
5596                }
5597                if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5598                    let health = String::from_utf8_lossy(&o.stdout);
5599                    if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5600                    {
5601                        out.push_str(&format!("{dev}: {}\n", line.trim()));
5602                    }
5603                }
5604            }
5605        } else {
5606            out.push_str("(install smartmontools for SMART health data)\n");
5607        }
5608    }
5609
5610    Ok(out.trim_end().to_string())
5611}
5612
5613// ── battery ───────────────────────────────────────────────────────────────────
5614
5615fn inspect_battery() -> Result<String, String> {
5616    let mut out = String::from("Host inspection: battery\n\n");
5617
5618    #[cfg(target_os = "windows")]
5619    {
5620        let script = r#"
5621try {
5622    $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5623    if (-not $bats) { "NO_BATTERY"; exit }
5624    
5625    # Modern Battery Health (Cycle count + Capacity health)
5626    $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5627    $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue 
5628    $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5629
5630    foreach ($b in $bats) {
5631        $state = switch ($b.BatteryStatus) {
5632            1 { "Discharging" }
5633            2 { "AC Power (Fully Charged)" }
5634            3 { "AC Power (Charging)" }
5635            default { "Status $($b.BatteryStatus)" }
5636        }
5637        
5638        $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5639        $health = if ($static -and $full) {
5640             [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5641        } else { "unknown" }
5642
5643        $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5644    }
5645} catch { "ERROR:" + $_.Exception.Message }
5646"#;
5647        let output = Command::new("powershell")
5648            .args(["-NoProfile", "-Command", script])
5649            .output()
5650            .map_err(|e| format!("battery: {e}"))?;
5651
5652        let raw = String::from_utf8_lossy(&output.stdout);
5653        let text = raw.trim();
5654
5655        if text == "NO_BATTERY" {
5656            out.push_str("No battery detected — desktop or AC-only system.\n");
5657            return Ok(out.trim_end().to_string());
5658        }
5659        if text.starts_with("ERROR:") {
5660            out.push_str(&format!("Unable to query battery: {text}\n"));
5661            return Ok(out.trim_end().to_string());
5662        }
5663
5664        for line in text.lines() {
5665            let parts: Vec<&str> = line.split('|').collect();
5666            if parts.len() == 5 {
5667                let name = parts[0];
5668                let charge: i64 = parts[1].parse().unwrap_or(-1);
5669                let state = parts[2];
5670                let cycles = parts[3];
5671                let health = parts[4];
5672
5673                out.push_str(&format!("Battery: {name}\n"));
5674                if charge >= 0 {
5675                    let bar_filled = (charge as usize * 20) / 100;
5676                    out.push_str(&format!(
5677                        "  Charge: [{}{}] {}%\n",
5678                        "#".repeat(bar_filled),
5679                        ".".repeat(20 - bar_filled),
5680                        charge
5681                    ));
5682                }
5683                out.push_str(&format!("  Status: {state}\n"));
5684                out.push_str(&format!("  Cycles: {cycles}\n"));
5685                out.push_str(&format!(
5686                    "  Health: {health}% (Actual vs Design Capacity)\n\n"
5687                ));
5688            }
5689        }
5690    }
5691
5692    #[cfg(not(target_os = "windows"))]
5693    {
5694        let power_path = std::path::Path::new("/sys/class/power_supply");
5695        let mut found = false;
5696        if power_path.exists() {
5697            if let Ok(entries) = std::fs::read_dir(power_path) {
5698                for entry in entries.flatten() {
5699                    let p = entry.path();
5700                    if let Ok(t) = std::fs::read_to_string(p.join("type")) {
5701                        if t.trim() == "Battery" {
5702                            found = true;
5703                            let name = p
5704                                .file_name()
5705                                .unwrap_or_default()
5706                                .to_string_lossy()
5707                                .to_string();
5708                            out.push_str(&format!("Battery: {name}\n"));
5709                            let read = |f: &str| {
5710                                std::fs::read_to_string(p.join(f))
5711                                    .ok()
5712                                    .map(|s| s.trim().to_string())
5713                            };
5714                            if let Some(cap) = read("capacity") {
5715                                out.push_str(&format!("  Charge: {cap}%\n"));
5716                            }
5717                            if let Some(status) = read("status") {
5718                                out.push_str(&format!("  Status: {status}\n"));
5719                            }
5720                            if let (Some(full), Some(design)) =
5721                                (read("energy_full"), read("energy_full_design"))
5722                            {
5723                                if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
5724                                {
5725                                    if d > 0.0 {
5726                                        out.push_str(&format!(
5727                                            "  Wear level: {:.1}% of design capacity\n",
5728                                            (f / d) * 100.0
5729                                        ));
5730                                    }
5731                                }
5732                            }
5733                        }
5734                    }
5735                }
5736            }
5737        }
5738        if !found {
5739            out.push_str("No battery found.\n");
5740        }
5741    }
5742
5743    Ok(out.trim_end().to_string())
5744}
5745
5746// ── recent_crashes ────────────────────────────────────────────────────────────
5747
5748fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
5749    let mut out = String::from("Host inspection: recent_crashes\n\n");
5750    let n = max_entries.clamp(1, 30);
5751
5752    #[cfg(target_os = "windows")]
5753    {
5754        // BSODs / unexpected shutdowns (EventID 41 = kernel power, 1001 = BugCheck)
5755        let bsod_script = format!(
5756            r#"
5757try {{
5758    $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
5759    if ($events) {{
5760        $events | ForEach-Object {{
5761            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5762        }}
5763    }} else {{ "NO_BSOD" }}
5764}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5765        );
5766
5767        if let Ok(o) = Command::new("powershell")
5768            .args(["-NoProfile", "-Command", &bsod_script])
5769            .output()
5770        {
5771            let raw = String::from_utf8_lossy(&o.stdout);
5772            let text = raw.trim();
5773            if text == "NO_BSOD" {
5774                out.push_str("System crashes (BSOD/kernel): None in recent history\n");
5775            } else if text.starts_with("ERROR:") {
5776                out.push_str("System crashes: unable to query\n");
5777            } else {
5778                out.push_str("System crashes / unexpected shutdowns:\n");
5779                for line in text.lines() {
5780                    let parts: Vec<&str> = line.splitn(3, '|').collect();
5781                    if parts.len() >= 3 {
5782                        let time = parts[0];
5783                        let id = parts[1];
5784                        let msg = parts[2];
5785                        let label = if id == "41" {
5786                            "Unexpected shutdown"
5787                        } else {
5788                            "BSOD (BugCheck)"
5789                        };
5790                        out.push_str(&format!("  [{time}] {label}: {msg}\n"));
5791                    }
5792                }
5793                out.push('\n');
5794            }
5795        }
5796
5797        // Application crashes (EventID 1000 = app crash, 1002 = app hang)
5798        let app_script = format!(
5799            r#"
5800try {{
5801    $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
5802    if ($crashes) {{
5803        $crashes | ForEach-Object {{
5804            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5805        }}
5806    }} else {{ "NO_CRASHES" }}
5807}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
5808        );
5809
5810        if let Ok(o) = Command::new("powershell")
5811            .args(["-NoProfile", "-Command", &app_script])
5812            .output()
5813        {
5814            let raw = String::from_utf8_lossy(&o.stdout);
5815            let text = raw.trim();
5816            if text == "NO_CRASHES" {
5817                out.push_str("Application crashes: None in recent history\n");
5818            } else if text.starts_with("ERROR_APP:") {
5819                out.push_str("Application crashes: unable to query\n");
5820            } else {
5821                out.push_str("Application crashes:\n");
5822                for line in text.lines().take(n) {
5823                    let parts: Vec<&str> = line.splitn(2, '|').collect();
5824                    if parts.len() >= 2 {
5825                        out.push_str(&format!("  [{}] {}\n", parts[0], parts[1]));
5826                    }
5827                }
5828            }
5829        }
5830    }
5831
5832    #[cfg(not(target_os = "windows"))]
5833    {
5834        let n_str = n.to_string();
5835        if let Ok(o) = Command::new("journalctl")
5836            .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
5837            .output()
5838        {
5839            let text = String::from_utf8_lossy(&o.stdout);
5840            let trimmed = text.trim();
5841            if trimmed.is_empty() || trimmed.contains("No entries") {
5842                out.push_str("No kernel panics or critical crashes found.\n");
5843            } else {
5844                out.push_str("Kernel critical events:\n");
5845                out.push_str(trimmed);
5846                out.push('\n');
5847            }
5848        }
5849        if let Ok(o) = Command::new("coredumpctl")
5850            .args(["list", "--no-pager"])
5851            .output()
5852        {
5853            let text = String::from_utf8_lossy(&o.stdout);
5854            let count = text
5855                .lines()
5856                .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
5857                .count();
5858            if count > 0 {
5859                out.push_str(&format!(
5860                    "\nCore dumps on file: {count}\n  → Run: coredumpctl list\n"
5861                ));
5862            }
5863        }
5864    }
5865
5866    Ok(out.trim_end().to_string())
5867}
5868
5869// ── scheduled_tasks ───────────────────────────────────────────────────────────
5870
5871fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
5872    let mut out = String::from("Host inspection: scheduled_tasks\n\n");
5873    let n = max_entries.clamp(1, 30);
5874
5875    #[cfg(target_os = "windows")]
5876    {
5877        let script = format!(
5878            r#"
5879try {{
5880    $tasks = Get-ScheduledTask -ErrorAction Stop |
5881        Where-Object {{ $_.State -ne 'Disabled' }} |
5882        ForEach-Object {{
5883            $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
5884            $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
5885                $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
5886            }} else {{ "never" }}
5887            $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
5888            $exec = ($_.Actions | Select-Object -First 1).Execute
5889            if (-not $exec) {{ $exec = "(no exec)" }}
5890            $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
5891        }}
5892    $tasks | Select-Object -First {n}
5893}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5894        );
5895
5896        let output = Command::new("powershell")
5897            .args(["-NoProfile", "-Command", &script])
5898            .output()
5899            .map_err(|e| format!("scheduled_tasks: {e}"))?;
5900
5901        let raw = String::from_utf8_lossy(&output.stdout);
5902        let text = raw.trim();
5903
5904        if text.starts_with("ERROR:") {
5905            out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
5906        } else if text.is_empty() {
5907            out.push_str("No active scheduled tasks found.\n");
5908        } else {
5909            out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
5910            for line in text.lines() {
5911                let parts: Vec<&str> = line.splitn(6, '|').collect();
5912                if parts.len() >= 5 {
5913                    let name = parts[0];
5914                    let path = parts[1];
5915                    let state = parts[2];
5916                    let last = parts[3];
5917                    let res = parts[4];
5918                    let exec = parts.get(5).unwrap_or(&"").trim();
5919                    let display_path = path.trim_matches('\\');
5920                    let display_path = if display_path.is_empty() {
5921                        "Root"
5922                    } else {
5923                        display_path
5924                    };
5925                    out.push_str(&format!("  {name} [{display_path}]\n"));
5926                    out.push_str(&format!(
5927                        "    State: {state} | Last run: {last} | Result: {res}\n"
5928                    ));
5929                    if !exec.is_empty() && exec != "(no exec)" {
5930                        let short = if exec.len() > 80 { &exec[..80] } else { exec };
5931                        out.push_str(&format!("    Runs: {short}\n"));
5932                    }
5933                }
5934            }
5935        }
5936    }
5937
5938    #[cfg(not(target_os = "windows"))]
5939    {
5940        if let Ok(o) = Command::new("systemctl")
5941            .args(["list-timers", "--no-pager", "--all"])
5942            .output()
5943        {
5944            let text = String::from_utf8_lossy(&o.stdout);
5945            out.push_str("Systemd timers:\n");
5946            for l in text
5947                .lines()
5948                .filter(|l| {
5949                    !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
5950                })
5951                .take(n)
5952            {
5953                out.push_str(&format!("  {l}\n"));
5954            }
5955            out.push('\n');
5956        }
5957        if let Ok(o) = Command::new("crontab").arg("-l").output() {
5958            let text = String::from_utf8_lossy(&o.stdout);
5959            let jobs: Vec<&str> = text
5960                .lines()
5961                .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
5962                .collect();
5963            if !jobs.is_empty() {
5964                out.push_str("User crontab:\n");
5965                for j in jobs.iter().take(n) {
5966                    out.push_str(&format!("  {j}\n"));
5967                }
5968            }
5969        }
5970    }
5971
5972    Ok(out.trim_end().to_string())
5973}
5974
5975// ── dev_conflicts ─────────────────────────────────────────────────────────────
5976
5977fn inspect_dev_conflicts() -> Result<String, String> {
5978    let mut out = String::from("Host inspection: dev_conflicts\n\n");
5979    let mut conflicts: Vec<String> = Vec::new();
5980    let mut notes: Vec<String> = Vec::new();
5981
5982    // ── Node.js / version managers ────────────────────────────────────────────
5983    {
5984        let node_ver = Command::new("node")
5985            .arg("--version")
5986            .output()
5987            .ok()
5988            .and_then(|o| String::from_utf8(o.stdout).ok())
5989            .map(|s| s.trim().to_string());
5990        let nvm_active = Command::new("nvm")
5991            .arg("current")
5992            .output()
5993            .ok()
5994            .and_then(|o| String::from_utf8(o.stdout).ok())
5995            .map(|s| s.trim().to_string())
5996            .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
5997        let fnm_active = Command::new("fnm")
5998            .arg("current")
5999            .output()
6000            .ok()
6001            .and_then(|o| String::from_utf8(o.stdout).ok())
6002            .map(|s| s.trim().to_string())
6003            .filter(|s| !s.is_empty() && !s.contains("none"));
6004        let volta_active = Command::new("volta")
6005            .args(["which", "node"])
6006            .output()
6007            .ok()
6008            .and_then(|o| String::from_utf8(o.stdout).ok())
6009            .map(|s| s.trim().to_string())
6010            .filter(|s| !s.is_empty());
6011
6012        out.push_str("Node.js:\n");
6013        if let Some(ref v) = node_ver {
6014            out.push_str(&format!("  Active: {v}\n"));
6015        } else {
6016            out.push_str("  Not installed\n");
6017        }
6018        let managers: Vec<&str> = [
6019            nvm_active.as_deref(),
6020            fnm_active.as_deref(),
6021            volta_active.as_deref(),
6022        ]
6023        .iter()
6024        .filter_map(|x| *x)
6025        .collect();
6026        if managers.len() > 1 {
6027            conflicts.push(format!(
6028                "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
6029            ));
6030        } else if !managers.is_empty() {
6031            out.push_str(&format!("  Version manager: {}\n", managers[0]));
6032        }
6033        out.push('\n');
6034    }
6035
6036    // ── Python ────────────────────────────────────────────────────────────────
6037    {
6038        let py3 = Command::new("python3")
6039            .arg("--version")
6040            .output()
6041            .ok()
6042            .and_then(|o| {
6043                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6044                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6045                let v = if stdout.is_empty() { stderr } else { stdout };
6046                if v.is_empty() {
6047                    None
6048                } else {
6049                    Some(v)
6050                }
6051            });
6052        let py = Command::new("python")
6053            .arg("--version")
6054            .output()
6055            .ok()
6056            .and_then(|o| {
6057                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
6058                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
6059                let v = if stdout.is_empty() { stderr } else { stdout };
6060                if v.is_empty() {
6061                    None
6062                } else {
6063                    Some(v)
6064                }
6065            });
6066        let pyenv = Command::new("pyenv")
6067            .arg("version")
6068            .output()
6069            .ok()
6070            .and_then(|o| String::from_utf8(o.stdout).ok())
6071            .map(|s| s.trim().to_string())
6072            .filter(|s| !s.is_empty());
6073        let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
6074
6075        out.push_str("Python:\n");
6076        match (&py3, &py) {
6077            (Some(v3), Some(v)) if v3 != v => {
6078                out.push_str(&format!("  python3: {v3}\n  python:  {v}\n"));
6079                if v.contains("2.") {
6080                    conflicts.push(
6081                        "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
6082                    );
6083                } else {
6084                    notes.push(
6085                        "python and python3 resolve to different minor versions.".to_string(),
6086                    );
6087                }
6088            }
6089            (Some(v3), None) => out.push_str(&format!("  python3: {v3}\n")),
6090            (None, Some(v)) => out.push_str(&format!("  python: {v}\n")),
6091            (Some(v3), Some(_)) => out.push_str(&format!("  {v3}\n")),
6092            (None, None) => out.push_str("  Not installed\n"),
6093        }
6094        if let Some(ref pe) = pyenv {
6095            out.push_str(&format!("  pyenv: {pe}\n"));
6096        }
6097        if let Some(env) = conda_env {
6098            if env == "base" {
6099                notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
6100            } else {
6101                out.push_str(&format!("  conda env: {env}\n"));
6102            }
6103        }
6104        out.push('\n');
6105    }
6106
6107    // ── Rust / Cargo ──────────────────────────────────────────────────────────
6108    {
6109        let toolchain = Command::new("rustup")
6110            .args(["show", "active-toolchain"])
6111            .output()
6112            .ok()
6113            .and_then(|o| String::from_utf8(o.stdout).ok())
6114            .map(|s| s.trim().to_string())
6115            .filter(|s| !s.is_empty());
6116        let cargo_ver = Command::new("cargo")
6117            .arg("--version")
6118            .output()
6119            .ok()
6120            .and_then(|o| String::from_utf8(o.stdout).ok())
6121            .map(|s| s.trim().to_string());
6122        let rustc_ver = Command::new("rustc")
6123            .arg("--version")
6124            .output()
6125            .ok()
6126            .and_then(|o| String::from_utf8(o.stdout).ok())
6127            .map(|s| s.trim().to_string());
6128
6129        out.push_str("Rust:\n");
6130        if let Some(ref t) = toolchain {
6131            out.push_str(&format!("  Active toolchain: {t}\n"));
6132        }
6133        if let Some(ref c) = cargo_ver {
6134            out.push_str(&format!("  {c}\n"));
6135        }
6136        if let Some(ref r) = rustc_ver {
6137            out.push_str(&format!("  {r}\n"));
6138        }
6139        if cargo_ver.is_none() && rustc_ver.is_none() {
6140            out.push_str("  Not installed\n");
6141        }
6142
6143        // Detect system rust that might shadow rustup
6144        #[cfg(not(target_os = "windows"))]
6145        if let Ok(o) = Command::new("which").arg("rustc").output() {
6146            let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
6147            if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
6148                conflicts.push(format!(
6149                    "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
6150                ));
6151            }
6152        }
6153        out.push('\n');
6154    }
6155
6156    // ── Git ───────────────────────────────────────────────────────────────────
6157    {
6158        let git_ver = Command::new("git")
6159            .arg("--version")
6160            .output()
6161            .ok()
6162            .and_then(|o| String::from_utf8(o.stdout).ok())
6163            .map(|s| s.trim().to_string());
6164        out.push_str("Git:\n");
6165        if let Some(ref v) = git_ver {
6166            out.push_str(&format!("  {v}\n"));
6167            let email = Command::new("git")
6168                .args(["config", "--global", "user.email"])
6169                .output()
6170                .ok()
6171                .and_then(|o| String::from_utf8(o.stdout).ok())
6172                .map(|s| s.trim().to_string());
6173            if let Some(ref e) = email {
6174                if e.is_empty() {
6175                    notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6176                } else {
6177                    out.push_str(&format!("  user.email: {e}\n"));
6178                }
6179            }
6180            let gpg_sign = Command::new("git")
6181                .args(["config", "--global", "commit.gpgsign"])
6182                .output()
6183                .ok()
6184                .and_then(|o| String::from_utf8(o.stdout).ok())
6185                .map(|s| s.trim().to_string());
6186            if gpg_sign.as_deref() == Some("true") {
6187                let key = Command::new("git")
6188                    .args(["config", "--global", "user.signingkey"])
6189                    .output()
6190                    .ok()
6191                    .and_then(|o| String::from_utf8(o.stdout).ok())
6192                    .map(|s| s.trim().to_string());
6193                if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6194                    conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6195                }
6196            }
6197        } else {
6198            out.push_str("  Not installed\n");
6199        }
6200        out.push('\n');
6201    }
6202
6203    // ── PATH duplicates ───────────────────────────────────────────────────────
6204    {
6205        let path_env = std::env::var("PATH").unwrap_or_default();
6206        let sep = if cfg!(windows) { ';' } else { ':' };
6207        let mut seen = HashSet::new();
6208        let mut dupes: Vec<String> = Vec::new();
6209        for p in path_env.split(sep) {
6210            let norm = p.trim().to_lowercase();
6211            if !norm.is_empty() && !seen.insert(norm) {
6212                dupes.push(p.to_string());
6213            }
6214        }
6215        if !dupes.is_empty() {
6216            let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6217            notes.push(format!(
6218                "Duplicate PATH entries: {} {}",
6219                shown.join(", "),
6220                if dupes.len() > 3 {
6221                    format!("+{} more", dupes.len() - 3)
6222                } else {
6223                    String::new()
6224                }
6225            ));
6226        }
6227    }
6228
6229    // ── Summary ───────────────────────────────────────────────────────────────
6230    if conflicts.is_empty() && notes.is_empty() {
6231        out.push_str("No conflicts detected — dev environment looks clean.\n");
6232    } else {
6233        if !conflicts.is_empty() {
6234            out.push_str("CONFLICTS:\n");
6235            for c in &conflicts {
6236                out.push_str(&format!("  [!] {c}\n"));
6237            }
6238            out.push('\n');
6239        }
6240        if !notes.is_empty() {
6241            out.push_str("NOTES:\n");
6242            for n in &notes {
6243                out.push_str(&format!("  [-] {n}\n"));
6244            }
6245        }
6246    }
6247
6248    Ok(out.trim_end().to_string())
6249}
6250
6251// ── connectivity ──────────────────────────────────────────────────────────────
6252
6253fn inspect_connectivity() -> Result<String, String> {
6254    let mut out = String::from("Host inspection: connectivity\n\n");
6255
6256    #[cfg(target_os = "windows")]
6257    {
6258        let inet_script = r#"
6259try {
6260    $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6261    if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6262} catch { "ERROR:" + $_.Exception.Message }
6263"#;
6264        if let Ok(o) = Command::new("powershell")
6265            .args(["-NoProfile", "-Command", inet_script])
6266            .output()
6267        {
6268            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6269            match text.as_str() {
6270                "REACHABLE" => out.push_str("Internet: reachable\n"),
6271                "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6272                _ => out.push_str(&format!(
6273                    "Internet: {}\n",
6274                    text.trim_start_matches("ERROR:").trim()
6275                )),
6276            }
6277        }
6278
6279        let dns_script = r#"
6280try {
6281    Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6282    "DNS:ok"
6283} catch { "DNS:fail:" + $_.Exception.Message }
6284"#;
6285        if let Ok(o) = Command::new("powershell")
6286            .args(["-NoProfile", "-Command", dns_script])
6287            .output()
6288        {
6289            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6290            if text == "DNS:ok" {
6291                out.push_str("DNS: resolving correctly\n");
6292            } else {
6293                let detail = text.trim_start_matches("DNS:fail:").trim();
6294                out.push_str(&format!("DNS: failed — {}\n", detail));
6295            }
6296        }
6297
6298        let gw_script = r#"
6299(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6300"#;
6301        if let Ok(o) = Command::new("powershell")
6302            .args(["-NoProfile", "-Command", gw_script])
6303            .output()
6304        {
6305            let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6306            if !gw.is_empty() && gw != "0.0.0.0" {
6307                out.push_str(&format!("Default gateway: {}\n", gw));
6308            }
6309        }
6310    }
6311
6312    #[cfg(not(target_os = "windows"))]
6313    {
6314        let reachable = Command::new("ping")
6315            .args(["-c", "1", "-W", "2", "8.8.8.8"])
6316            .output()
6317            .map(|o| o.status.success())
6318            .unwrap_or(false);
6319        out.push_str(if reachable {
6320            "Internet: reachable\n"
6321        } else {
6322            "Internet: unreachable\n"
6323        });
6324        let dns_ok = Command::new("getent")
6325            .args(["hosts", "dns.google"])
6326            .output()
6327            .map(|o| o.status.success())
6328            .unwrap_or(false);
6329        out.push_str(if dns_ok {
6330            "DNS: resolving correctly\n"
6331        } else {
6332            "DNS: failed\n"
6333        });
6334        if let Ok(o) = Command::new("ip")
6335            .args(["route", "show", "default"])
6336            .output()
6337        {
6338            let text = String::from_utf8_lossy(&o.stdout);
6339            if let Some(line) = text.lines().next() {
6340                out.push_str(&format!("Default gateway: {}\n", line.trim()));
6341            }
6342        }
6343    }
6344
6345    Ok(out.trim_end().to_string())
6346}
6347
6348// ── wifi ──────────────────────────────────────────────────────────────────────
6349
6350fn inspect_wifi() -> Result<String, String> {
6351    let mut out = String::from("Host inspection: wifi\n\n");
6352
6353    #[cfg(target_os = "windows")]
6354    {
6355        let output = Command::new("netsh")
6356            .args(["wlan", "show", "interfaces"])
6357            .output()
6358            .map_err(|e| format!("wifi: {e}"))?;
6359        let text = String::from_utf8_lossy(&output.stdout).to_string();
6360
6361        if text.contains("There is no wireless interface") || text.trim().is_empty() {
6362            out.push_str("No wireless interface detected on this machine.\n");
6363            return Ok(out.trim_end().to_string());
6364        }
6365
6366        let fields = [
6367            ("SSID", "SSID"),
6368            ("State", "State"),
6369            ("Signal", "Signal"),
6370            ("Radio type", "Radio type"),
6371            ("Channel", "Channel"),
6372            ("Receive rate (Mbps)", "Download speed (Mbps)"),
6373            ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6374            ("Authentication", "Authentication"),
6375            ("Network type", "Network type"),
6376        ];
6377
6378        let mut any = false;
6379        for line in text.lines() {
6380            let trimmed = line.trim();
6381            for (key, label) in &fields {
6382                if trimmed.starts_with(key) && trimmed.contains(':') {
6383                    let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6384                    if !val.is_empty() {
6385                        out.push_str(&format!("  {label}: {val}\n"));
6386                        any = true;
6387                    }
6388                }
6389            }
6390        }
6391        if !any {
6392            out.push_str("  (Wi-Fi adapter disconnected or no active connection)\n");
6393        }
6394    }
6395
6396    #[cfg(not(target_os = "windows"))]
6397    {
6398        if let Ok(o) = Command::new("nmcli")
6399            .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6400            .output()
6401        {
6402            let text = String::from_utf8_lossy(&o.stdout).to_string();
6403            let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6404            if lines.is_empty() {
6405                out.push_str("No Wi-Fi devices found.\n");
6406            } else {
6407                for l in lines {
6408                    out.push_str(&format!("  {l}\n"));
6409                }
6410            }
6411        } else if let Ok(o) = Command::new("iwconfig").output() {
6412            let text = String::from_utf8_lossy(&o.stdout).to_string();
6413            if !text.trim().is_empty() {
6414                out.push_str(text.trim());
6415                out.push('\n');
6416            }
6417        } else {
6418            out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
6419        }
6420    }
6421
6422    Ok(out.trim_end().to_string())
6423}
6424
6425// ── connections ───────────────────────────────────────────────────────────────
6426
6427fn inspect_connections(max_entries: usize) -> Result<String, String> {
6428    let mut out = String::from("Host inspection: connections\n\n");
6429    let n = max_entries.clamp(1, 25);
6430
6431    #[cfg(target_os = "windows")]
6432    {
6433        let script = format!(
6434            r#"
6435try {{
6436    $procs = @{{}}
6437    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
6438    $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
6439        Sort-Object OwningProcess
6440    "TOTAL:" + $all.Count
6441    $all | Select-Object -First {n} | ForEach-Object {{
6442        $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
6443        $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
6444    }}
6445}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6446        );
6447
6448        let output = Command::new("powershell")
6449            .args(["-NoProfile", "-Command", &script])
6450            .output()
6451            .map_err(|e| format!("connections: {e}"))?;
6452
6453        let raw = String::from_utf8_lossy(&output.stdout);
6454        let text = raw.trim();
6455
6456        if text.starts_with("ERROR:") {
6457            out.push_str(&format!("Unable to query connections: {text}\n"));
6458        } else {
6459            let mut total = 0usize;
6460            let mut rows = Vec::new();
6461            for line in text.lines() {
6462                if let Some(rest) = line.strip_prefix("TOTAL:") {
6463                    total = rest.trim().parse().unwrap_or(0);
6464                } else {
6465                    rows.push(line);
6466                }
6467            }
6468            out.push_str(&format!("Established TCP connections: {total}\n\n"));
6469            for row in &rows {
6470                let parts: Vec<&str> = row.splitn(4, '|').collect();
6471                if parts.len() == 4 {
6472                    out.push_str(&format!(
6473                        "  {:<15} (pid {:<5}) | {} → {}\n",
6474                        parts[0], parts[1], parts[2], parts[3]
6475                    ));
6476                }
6477            }
6478            if total > n {
6479                out.push_str(&format!(
6480                    "\n  ... {} more connections not shown\n",
6481                    total.saturating_sub(n)
6482                ));
6483            }
6484        }
6485    }
6486
6487    #[cfg(not(target_os = "windows"))]
6488    {
6489        if let Ok(o) = Command::new("ss")
6490            .args(["-tnp", "state", "established"])
6491            .output()
6492        {
6493            let text = String::from_utf8_lossy(&o.stdout);
6494            let lines: Vec<&str> = text
6495                .lines()
6496                .skip(1)
6497                .filter(|l| !l.trim().is_empty())
6498                .collect();
6499            out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
6500            for line in lines.iter().take(n) {
6501                out.push_str(&format!("  {}\n", line.trim()));
6502            }
6503            if lines.len() > n {
6504                out.push_str(&format!("\n  ... {} more not shown\n", lines.len() - n));
6505            }
6506        } else {
6507            out.push_str("ss not available — install iproute2\n");
6508        }
6509    }
6510
6511    Ok(out.trim_end().to_string())
6512}
6513
6514// ── vpn ───────────────────────────────────────────────────────────────────────
6515
6516fn inspect_vpn() -> Result<String, String> {
6517    let mut out = String::from("Host inspection: vpn\n\n");
6518
6519    #[cfg(target_os = "windows")]
6520    {
6521        let script = r#"
6522try {
6523    $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
6524        $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
6525        $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
6526    }
6527    if ($vpn) {
6528        foreach ($a in $vpn) {
6529            $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
6530        }
6531    } else { "NONE" }
6532} catch { "ERROR:" + $_.Exception.Message }
6533"#;
6534        let output = Command::new("powershell")
6535            .args(["-NoProfile", "-Command", script])
6536            .output()
6537            .map_err(|e| format!("vpn: {e}"))?;
6538
6539        let raw = String::from_utf8_lossy(&output.stdout);
6540        let text = raw.trim();
6541
6542        if text == "NONE" {
6543            out.push_str("No VPN adapters detected — no active VPN connection found.\n");
6544        } else if text.starts_with("ERROR:") {
6545            out.push_str(&format!("Unable to query adapters: {text}\n"));
6546        } else {
6547            out.push_str("VPN adapters:\n\n");
6548            for line in text.lines() {
6549                let parts: Vec<&str> = line.splitn(4, '|').collect();
6550                if parts.len() >= 3 {
6551                    let name = parts[0];
6552                    let desc = parts[1];
6553                    let status = parts[2];
6554                    let media = parts.get(3).unwrap_or(&"unknown");
6555                    let label = if status.trim() == "Up" {
6556                        "CONNECTED"
6557                    } else {
6558                        "disconnected"
6559                    };
6560                    out.push_str(&format!(
6561                        "  {name} [{label}]\n    {desc}\n    Status: {status} | Media: {media}\n\n"
6562                    ));
6563                }
6564            }
6565        }
6566
6567        // Windows built-in VPN connections
6568        let ras_script = r#"
6569try {
6570    $c = Get-VpnConnection -ErrorAction Stop
6571    if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
6572    else { "NO_RAS" }
6573} catch { "NO_RAS" }
6574"#;
6575        if let Ok(o) = Command::new("powershell")
6576            .args(["-NoProfile", "-Command", ras_script])
6577            .output()
6578        {
6579            let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
6580            if t != "NO_RAS" && !t.is_empty() {
6581                out.push_str("Windows VPN connections:\n");
6582                for line in t.lines() {
6583                    let parts: Vec<&str> = line.splitn(3, '|').collect();
6584                    if parts.len() >= 2 {
6585                        let name = parts[0];
6586                        let status = parts[1];
6587                        let server = parts.get(2).unwrap_or(&"");
6588                        out.push_str(&format!("  {name} → {server} [{status}]\n"));
6589                    }
6590                }
6591            }
6592        }
6593    }
6594
6595    #[cfg(not(target_os = "windows"))]
6596    {
6597        if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
6598            let text = String::from_utf8_lossy(&o.stdout);
6599            let vpn_ifaces: Vec<&str> = text
6600                .lines()
6601                .filter(|l| {
6602                    l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
6603                })
6604                .collect();
6605            if vpn_ifaces.is_empty() {
6606                out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
6607            } else {
6608                out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
6609                for l in vpn_ifaces {
6610                    out.push_str(&format!("  {}\n", l.trim()));
6611                }
6612            }
6613        }
6614    }
6615
6616    Ok(out.trim_end().to_string())
6617}
6618
6619// ── proxy ─────────────────────────────────────────────────────────────────────
6620
6621fn inspect_proxy() -> Result<String, String> {
6622    let mut out = String::from("Host inspection: proxy\n\n");
6623
6624    #[cfg(target_os = "windows")]
6625    {
6626        let script = r#"
6627$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
6628if ($ie) {
6629    "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
6630} else { "NONE" }
6631"#;
6632        if let Ok(o) = Command::new("powershell")
6633            .args(["-NoProfile", "-Command", script])
6634            .output()
6635        {
6636            let raw = String::from_utf8_lossy(&o.stdout);
6637            let text = raw.trim();
6638            if text != "NONE" && !text.is_empty() {
6639                let get = |key: &str| -> &str {
6640                    text.split('|')
6641                        .find(|s| s.starts_with(key))
6642                        .and_then(|s| s.splitn(2, ':').nth(1))
6643                        .unwrap_or("")
6644                };
6645                let enabled = get("ENABLE");
6646                let server = get("SERVER");
6647                let overrides = get("OVERRIDE");
6648                out.push_str("WinINET / IE proxy:\n");
6649                out.push_str(&format!(
6650                    "  Enabled: {}\n",
6651                    if enabled == "1" { "yes" } else { "no" }
6652                ));
6653                if !server.is_empty() && server != "None" {
6654                    out.push_str(&format!("  Proxy server: {server}\n"));
6655                }
6656                if !overrides.is_empty() && overrides != "None" {
6657                    out.push_str(&format!("  Bypass list: {overrides}\n"));
6658                }
6659                out.push('\n');
6660            }
6661        }
6662
6663        if let Ok(o) = Command::new("netsh")
6664            .args(["winhttp", "show", "proxy"])
6665            .output()
6666        {
6667            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6668            out.push_str("WinHTTP proxy:\n");
6669            for line in text.lines() {
6670                let l = line.trim();
6671                if !l.is_empty() {
6672                    out.push_str(&format!("  {l}\n"));
6673                }
6674            }
6675            out.push('\n');
6676        }
6677
6678        let mut env_found = false;
6679        for var in &[
6680            "http_proxy",
6681            "https_proxy",
6682            "HTTP_PROXY",
6683            "HTTPS_PROXY",
6684            "no_proxy",
6685            "NO_PROXY",
6686        ] {
6687            if let Ok(val) = std::env::var(var) {
6688                if !env_found {
6689                    out.push_str("Environment proxy variables:\n");
6690                    env_found = true;
6691                }
6692                out.push_str(&format!("  {var}: {val}\n"));
6693            }
6694        }
6695        if !env_found {
6696            out.push_str("No proxy environment variables set.\n");
6697        }
6698    }
6699
6700    #[cfg(not(target_os = "windows"))]
6701    {
6702        let mut found = false;
6703        for var in &[
6704            "http_proxy",
6705            "https_proxy",
6706            "HTTP_PROXY",
6707            "HTTPS_PROXY",
6708            "no_proxy",
6709            "NO_PROXY",
6710            "ALL_PROXY",
6711            "all_proxy",
6712        ] {
6713            if let Ok(val) = std::env::var(var) {
6714                if !found {
6715                    out.push_str("Proxy environment variables:\n");
6716                    found = true;
6717                }
6718                out.push_str(&format!("  {var}: {val}\n"));
6719            }
6720        }
6721        if !found {
6722            out.push_str("No proxy environment variables set.\n");
6723        }
6724        if let Ok(content) = std::fs::read_to_string("/etc/environment") {
6725            let proxy_lines: Vec<&str> = content
6726                .lines()
6727                .filter(|l| l.to_lowercase().contains("proxy"))
6728                .collect();
6729            if !proxy_lines.is_empty() {
6730                out.push_str("\nSystem proxy (/etc/environment):\n");
6731                for l in proxy_lines {
6732                    out.push_str(&format!("  {l}\n"));
6733                }
6734            }
6735        }
6736    }
6737
6738    Ok(out.trim_end().to_string())
6739}
6740
6741// ── firewall_rules ────────────────────────────────────────────────────────────
6742
6743fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
6744    let mut out = String::from("Host inspection: firewall_rules\n\n");
6745    let n = max_entries.clamp(1, 20);
6746
6747    #[cfg(target_os = "windows")]
6748    {
6749        let script = format!(
6750            r#"
6751try {{
6752    $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
6753        Where-Object {{
6754            $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
6755            $_.Owner -eq $null
6756        }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
6757    "TOTAL:" + $rules.Count
6758    $rules | ForEach-Object {{
6759        $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
6760        $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
6761        $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
6762    }}
6763}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6764        );
6765
6766        let output = Command::new("powershell")
6767            .args(["-NoProfile", "-Command", &script])
6768            .output()
6769            .map_err(|e| format!("firewall_rules: {e}"))?;
6770
6771        let raw = String::from_utf8_lossy(&output.stdout);
6772        let text = raw.trim();
6773
6774        if text.starts_with("ERROR:") {
6775            out.push_str(&format!(
6776                "Unable to query firewall rules: {}\n",
6777                text.trim_start_matches("ERROR:").trim()
6778            ));
6779            out.push_str("This query may require running as administrator.\n");
6780        } else if text.is_empty() {
6781            out.push_str("No non-default enabled firewall rules found.\n");
6782        } else {
6783            let mut total = 0usize;
6784            for line in text.lines() {
6785                if let Some(rest) = line.strip_prefix("TOTAL:") {
6786                    total = rest.trim().parse().unwrap_or(0);
6787                    out.push_str(&format!(
6788                        "Non-default enabled rules (showing up to {n}):\n\n"
6789                    ));
6790                } else {
6791                    let parts: Vec<&str> = line.splitn(4, '|').collect();
6792                    if parts.len() >= 3 {
6793                        let name = parts[0];
6794                        let dir = parts[1];
6795                        let action = parts[2];
6796                        let profile = parts.get(3).unwrap_or(&"Any");
6797                        let icon = if action == "Block" { "[!]" } else { "   " };
6798                        out.push_str(&format!(
6799                            "  {icon} [{dir}] {action}: {name} (profile: {profile})\n"
6800                        ));
6801                    }
6802                }
6803            }
6804            if total == 0 {
6805                out.push_str("No non-default enabled rules found.\n");
6806            }
6807        }
6808    }
6809
6810    #[cfg(not(target_os = "windows"))]
6811    {
6812        if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
6813            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6814            if !text.is_empty() {
6815                out.push_str(&text);
6816                out.push('\n');
6817            }
6818        } else if let Ok(o) = Command::new("iptables")
6819            .args(["-L", "-n", "--line-numbers"])
6820            .output()
6821        {
6822            let text = String::from_utf8_lossy(&o.stdout);
6823            for l in text.lines().take(n * 2) {
6824                out.push_str(&format!("  {l}\n"));
6825            }
6826        } else {
6827            out.push_str("ufw and iptables not available or insufficient permissions.\n");
6828        }
6829    }
6830
6831    Ok(out.trim_end().to_string())
6832}
6833
6834// ── traceroute ────────────────────────────────────────────────────────────────
6835
6836fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
6837    let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
6838    let hops = max_entries.clamp(5, 30);
6839
6840    #[cfg(target_os = "windows")]
6841    {
6842        let output = Command::new("tracert")
6843            .args(["-d", "-h", &hops.to_string(), host])
6844            .output()
6845            .map_err(|e| format!("tracert: {e}"))?;
6846        let raw = String::from_utf8_lossy(&output.stdout);
6847        let mut hop_count = 0usize;
6848        for line in raw.lines() {
6849            let trimmed = line.trim();
6850            if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
6851                hop_count += 1;
6852                out.push_str(&format!("  {trimmed}\n"));
6853            } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
6854                out.push_str(&format!("{trimmed}\n"));
6855            }
6856        }
6857        if hop_count == 0 {
6858            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6859        }
6860    }
6861
6862    #[cfg(not(target_os = "windows"))]
6863    {
6864        let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
6865            || std::path::Path::new("/usr/sbin/traceroute").exists()
6866        {
6867            "traceroute"
6868        } else {
6869            "tracepath"
6870        };
6871        let output = Command::new(cmd)
6872            .args(["-m", &hops.to_string(), "-n", host])
6873            .output()
6874            .map_err(|e| format!("{cmd}: {e}"))?;
6875        let raw = String::from_utf8_lossy(&output.stdout);
6876        let mut hop_count = 0usize;
6877        for line in raw.lines().take(hops + 2) {
6878            let trimmed = line.trim();
6879            if !trimmed.is_empty() {
6880                hop_count += 1;
6881                out.push_str(&format!("  {trimmed}\n"));
6882            }
6883        }
6884        if hop_count == 0 {
6885            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6886        }
6887    }
6888
6889    Ok(out.trim_end().to_string())
6890}
6891
6892// ── dns_cache ─────────────────────────────────────────────────────────────────
6893
6894fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
6895    let mut out = String::from("Host inspection: dns_cache\n\n");
6896    let n = max_entries.clamp(10, 100);
6897
6898    #[cfg(target_os = "windows")]
6899    {
6900        let output = Command::new("powershell")
6901            .args([
6902                "-NoProfile",
6903                "-Command",
6904                "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
6905            ])
6906            .output()
6907            .map_err(|e| format!("dns_cache: {e}"))?;
6908
6909        let raw = String::from_utf8_lossy(&output.stdout);
6910        let lines: Vec<&str> = raw.lines().skip(1).collect();
6911        let total = lines.len();
6912
6913        if total == 0 {
6914            out.push_str("DNS cache is empty or could not be read.\n");
6915        } else {
6916            out.push_str(&format!(
6917                "DNS cache entries (showing up to {n} of {total}):\n\n"
6918            ));
6919            let mut shown = 0usize;
6920            for line in lines.iter().take(n) {
6921                let cols: Vec<&str> = line.splitn(4, ',').collect();
6922                if cols.len() >= 3 {
6923                    let entry = cols[0].trim_matches('"');
6924                    let rtype = cols[1].trim_matches('"');
6925                    let data = cols[2].trim_matches('"');
6926                    let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
6927                    out.push_str(&format!("  {entry:<45} {rtype:<6} {data}  (TTL {ttl}s)\n"));
6928                    shown += 1;
6929                }
6930            }
6931            if total > shown {
6932                out.push_str(&format!("\n  ... and {} more entries\n", total - shown));
6933            }
6934        }
6935    }
6936
6937    #[cfg(not(target_os = "windows"))]
6938    {
6939        if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
6940            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6941            if !text.is_empty() {
6942                out.push_str("systemd-resolved statistics:\n");
6943                for line in text.lines().take(n) {
6944                    out.push_str(&format!("  {line}\n"));
6945                }
6946                out.push('\n');
6947            }
6948        }
6949        if let Ok(o) = Command::new("dscacheutil")
6950            .args(["-cachedump", "-entries", "Host"])
6951            .output()
6952        {
6953            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6954            if !text.is_empty() {
6955                out.push_str("DNS cache (macOS dscacheutil):\n");
6956                for line in text.lines().take(n) {
6957                    out.push_str(&format!("  {line}\n"));
6958                }
6959            } else {
6960                out.push_str("DNS cache is empty or not accessible on this platform.\n");
6961            }
6962        } else {
6963            out.push_str(
6964                "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
6965            );
6966        }
6967    }
6968
6969    Ok(out.trim_end().to_string())
6970}
6971
6972// ── arp ───────────────────────────────────────────────────────────────────────
6973
6974fn inspect_arp() -> Result<String, String> {
6975    let mut out = String::from("Host inspection: arp\n\n");
6976
6977    #[cfg(target_os = "windows")]
6978    {
6979        let output = Command::new("arp")
6980            .args(["-a"])
6981            .output()
6982            .map_err(|e| format!("arp: {e}"))?;
6983        let raw = String::from_utf8_lossy(&output.stdout);
6984        let mut count = 0usize;
6985        for line in raw.lines() {
6986            let t = line.trim();
6987            if t.is_empty() {
6988                continue;
6989            }
6990            out.push_str(&format!("  {t}\n"));
6991            if t.contains("dynamic") || t.contains("static") {
6992                count += 1;
6993            }
6994        }
6995        out.push_str(&format!("\nTotal entries: {count}\n"));
6996    }
6997
6998    #[cfg(not(target_os = "windows"))]
6999    {
7000        if let Ok(o) = Command::new("arp").args(["-n"]).output() {
7001            let raw = String::from_utf8_lossy(&o.stdout);
7002            let mut count = 0usize;
7003            for line in raw.lines() {
7004                let t = line.trim();
7005                if !t.is_empty() {
7006                    out.push_str(&format!("  {t}\n"));
7007                    count += 1;
7008                }
7009            }
7010            out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
7011        } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
7012            let raw = String::from_utf8_lossy(&o.stdout);
7013            let mut count = 0usize;
7014            for line in raw.lines() {
7015                let t = line.trim();
7016                if !t.is_empty() {
7017                    out.push_str(&format!("  {t}\n"));
7018                    count += 1;
7019                }
7020            }
7021            out.push_str(&format!("\nTotal entries: {count}\n"));
7022        } else {
7023            out.push_str("arp and ip neigh not available.\n");
7024        }
7025    }
7026
7027    Ok(out.trim_end().to_string())
7028}
7029
7030// ── route_table ───────────────────────────────────────────────────────────────
7031
7032fn inspect_route_table(max_entries: usize) -> Result<String, String> {
7033    let mut out = String::from("Host inspection: route_table\n\n");
7034    let n = max_entries.clamp(10, 50);
7035
7036    #[cfg(target_os = "windows")]
7037    {
7038        let script = r#"
7039try {
7040    $routes = Get-NetRoute -ErrorAction Stop |
7041        Where-Object { $_.RouteMetric -lt 9000 } |
7042        Sort-Object RouteMetric |
7043        Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
7044    "TOTAL:" + $routes.Count
7045    $routes | ForEach-Object {
7046        $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
7047    }
7048} catch { "ERROR:" + $_.Exception.Message }
7049"#;
7050        let output = Command::new("powershell")
7051            .args(["-NoProfile", "-Command", script])
7052            .output()
7053            .map_err(|e| format!("route_table: {e}"))?;
7054        let raw = String::from_utf8_lossy(&output.stdout);
7055        let text = raw.trim();
7056
7057        if text.starts_with("ERROR:") {
7058            out.push_str(&format!(
7059                "Unable to read route table: {}\n",
7060                text.trim_start_matches("ERROR:").trim()
7061            ));
7062        } else {
7063            let mut shown = 0usize;
7064            for line in text.lines() {
7065                if let Some(rest) = line.strip_prefix("TOTAL:") {
7066                    let total: usize = rest.trim().parse().unwrap_or(0);
7067                    out.push_str(&format!(
7068                        "Routing table (showing up to {n} of {total} routes):\n\n"
7069                    ));
7070                    out.push_str(&format!(
7071                        "  {:<22} {:<18} {:>8}  Interface\n",
7072                        "Destination", "Next Hop", "Metric"
7073                    ));
7074                    out.push_str(&format!("  {}\n", "-".repeat(70)));
7075                } else if shown < n {
7076                    let parts: Vec<&str> = line.splitn(4, '|').collect();
7077                    if parts.len() == 4 {
7078                        let dest = parts[0];
7079                        let hop =
7080                            if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
7081                                "on-link"
7082                            } else {
7083                                parts[1]
7084                            };
7085                        let metric = parts[2];
7086                        let iface = parts[3];
7087                        out.push_str(&format!("  {dest:<22} {hop:<18} {metric:>8}  {iface}\n"));
7088                        shown += 1;
7089                    }
7090                }
7091            }
7092        }
7093    }
7094
7095    #[cfg(not(target_os = "windows"))]
7096    {
7097        if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
7098            let raw = String::from_utf8_lossy(&o.stdout);
7099            let lines: Vec<&str> = raw.lines().collect();
7100            let total = lines.len();
7101            out.push_str(&format!(
7102                "Routing table (showing up to {n} of {total} routes):\n\n"
7103            ));
7104            for line in lines.iter().take(n) {
7105                out.push_str(&format!("  {line}\n"));
7106            }
7107            if total > n {
7108                out.push_str(&format!("\n  ... and {} more routes\n", total - n));
7109            }
7110        } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
7111            let raw = String::from_utf8_lossy(&o.stdout);
7112            for line in raw.lines().take(n) {
7113                out.push_str(&format!("  {line}\n"));
7114            }
7115        } else {
7116            out.push_str("ip route and netstat not available.\n");
7117        }
7118    }
7119
7120    Ok(out.trim_end().to_string())
7121}
7122
7123// ── env ───────────────────────────────────────────────────────────────────────
7124
7125fn inspect_env(max_entries: usize) -> Result<String, String> {
7126    let mut out = String::from("Host inspection: env\n\n");
7127    let n = max_entries.clamp(10, 50);
7128
7129    fn looks_like_secret(name: &str) -> bool {
7130        let n = name.to_uppercase();
7131        n.contains("KEY")
7132            || n.contains("SECRET")
7133            || n.contains("TOKEN")
7134            || n.contains("PASSWORD")
7135            || n.contains("PASSWD")
7136            || n.contains("CREDENTIAL")
7137            || n.contains("AUTH")
7138            || n.contains("CERT")
7139            || n.contains("PRIVATE")
7140    }
7141
7142    let known_dev_vars: &[&str] = &[
7143        "CARGO_HOME",
7144        "RUSTUP_HOME",
7145        "GOPATH",
7146        "GOROOT",
7147        "GOBIN",
7148        "JAVA_HOME",
7149        "ANDROID_HOME",
7150        "ANDROID_SDK_ROOT",
7151        "PYTHONPATH",
7152        "PYTHONHOME",
7153        "VIRTUAL_ENV",
7154        "CONDA_DEFAULT_ENV",
7155        "CONDA_PREFIX",
7156        "NODE_PATH",
7157        "NVM_DIR",
7158        "NVM_BIN",
7159        "PNPM_HOME",
7160        "DENO_INSTALL",
7161        "DENO_DIR",
7162        "DOTNET_ROOT",
7163        "NUGET_PACKAGES",
7164        "CMAKE_HOME",
7165        "VCPKG_ROOT",
7166        "AWS_PROFILE",
7167        "AWS_REGION",
7168        "AWS_DEFAULT_REGION",
7169        "GCP_PROJECT",
7170        "GOOGLE_CLOUD_PROJECT",
7171        "GOOGLE_APPLICATION_CREDENTIALS",
7172        "AZURE_SUBSCRIPTION_ID",
7173        "DATABASE_URL",
7174        "REDIS_URL",
7175        "MONGO_URI",
7176        "EDITOR",
7177        "VISUAL",
7178        "SHELL",
7179        "TERM",
7180        "XDG_CONFIG_HOME",
7181        "XDG_DATA_HOME",
7182        "XDG_CACHE_HOME",
7183        "HOME",
7184        "USERPROFILE",
7185        "APPDATA",
7186        "LOCALAPPDATA",
7187        "TEMP",
7188        "TMP",
7189        "COMPUTERNAME",
7190        "USERNAME",
7191        "USERDOMAIN",
7192        "PROCESSOR_ARCHITECTURE",
7193        "NUMBER_OF_PROCESSORS",
7194        "OS",
7195        "HOMEDRIVE",
7196        "HOMEPATH",
7197        "HTTP_PROXY",
7198        "HTTPS_PROXY",
7199        "NO_PROXY",
7200        "ALL_PROXY",
7201        "http_proxy",
7202        "https_proxy",
7203        "no_proxy",
7204        "DOCKER_HOST",
7205        "DOCKER_BUILDKIT",
7206        "COMPOSE_PROJECT_NAME",
7207        "KUBECONFIG",
7208        "KUBE_CONTEXT",
7209        "CI",
7210        "GITHUB_ACTIONS",
7211        "GITLAB_CI",
7212        "LMSTUDIO_HOME",
7213        "HEMATITE_URL",
7214    ];
7215
7216    let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7217    all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7218    let total = all_vars.len();
7219
7220    let mut dev_found: Vec<String> = Vec::new();
7221    let mut secret_found: Vec<String> = Vec::new();
7222
7223    for (k, v) in &all_vars {
7224        if k == "PATH" {
7225            continue;
7226        }
7227        if looks_like_secret(k) {
7228            secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7229        } else {
7230            let k_upper = k.to_uppercase();
7231            let is_known = known_dev_vars
7232                .iter()
7233                .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7234            if is_known {
7235                let display = if v.len() > 120 {
7236                    format!("{k} = {}…", &v[..117])
7237                } else {
7238                    format!("{k} = {v}")
7239                };
7240                dev_found.push(display);
7241            }
7242        }
7243    }
7244
7245    out.push_str(&format!("Total environment variables: {total}\n\n"));
7246
7247    if let Ok(p) = std::env::var("PATH") {
7248        let sep = if cfg!(target_os = "windows") {
7249            ';'
7250        } else {
7251            ':'
7252        };
7253        let count = p.split(sep).count();
7254        out.push_str(&format!(
7255            "PATH: {count} entries (use topic=path for full audit)\n\n"
7256        ));
7257    }
7258
7259    if !secret_found.is_empty() {
7260        out.push_str(&format!(
7261            "=== Secret/credential variables ({} detected, values hidden) ===\n",
7262            secret_found.len()
7263        ));
7264        for s in secret_found.iter().take(n) {
7265            out.push_str(&format!("  {s}\n"));
7266        }
7267        out.push('\n');
7268    }
7269
7270    if !dev_found.is_empty() {
7271        out.push_str(&format!(
7272            "=== Developer & tool variables ({}) ===\n",
7273            dev_found.len()
7274        ));
7275        for d in dev_found.iter().take(n) {
7276            out.push_str(&format!("  {d}\n"));
7277        }
7278        out.push('\n');
7279    }
7280
7281    let other_count = all_vars
7282        .iter()
7283        .filter(|(k, _)| {
7284            k != "PATH"
7285                && !looks_like_secret(k)
7286                && !known_dev_vars
7287                    .iter()
7288                    .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7289        })
7290        .count();
7291    if other_count > 0 {
7292        out.push_str(&format!(
7293            "Other variables: {other_count} (use 'env' in shell to see all)\n"
7294        ));
7295    }
7296
7297    Ok(out.trim_end().to_string())
7298}
7299
7300// ── hosts_file ────────────────────────────────────────────────────────────────
7301
7302fn inspect_hosts_file() -> Result<String, String> {
7303    let mut out = String::from("Host inspection: hosts_file\n\n");
7304
7305    let hosts_path = if cfg!(target_os = "windows") {
7306        std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7307    } else {
7308        std::path::PathBuf::from("/etc/hosts")
7309    };
7310
7311    out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7312
7313    match fs::read_to_string(&hosts_path) {
7314        Ok(content) => {
7315            let mut active_entries: Vec<String> = Vec::new();
7316            let mut comment_lines = 0usize;
7317            let mut blank_lines = 0usize;
7318
7319            for line in content.lines() {
7320                let t = line.trim();
7321                if t.is_empty() {
7322                    blank_lines += 1;
7323                } else if t.starts_with('#') {
7324                    comment_lines += 1;
7325                } else {
7326                    active_entries.push(line.to_string());
7327                }
7328            }
7329
7330            out.push_str(&format!(
7331                "Active entries: {}  |  Comment lines: {}  |  Blank lines: {}\n\n",
7332                active_entries.len(),
7333                comment_lines,
7334                blank_lines
7335            ));
7336
7337            if active_entries.is_empty() {
7338                out.push_str(
7339                    "No active host entries (file contains only comments/blanks — standard default state).\n",
7340                );
7341            } else {
7342                out.push_str("=== Active entries ===\n");
7343                for entry in &active_entries {
7344                    out.push_str(&format!("  {entry}\n"));
7345                }
7346                out.push('\n');
7347
7348                let custom: Vec<&String> = active_entries
7349                    .iter()
7350                    .filter(|e| {
7351                        let t = e.trim_start();
7352                        !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7353                    })
7354                    .collect();
7355                if !custom.is_empty() {
7356                    out.push_str(&format!(
7357                        "[!] Custom (non-loopback) entries: {}\n",
7358                        custom.len()
7359                    ));
7360                    for e in &custom {
7361                        out.push_str(&format!("  {e}\n"));
7362                    }
7363                } else {
7364                    out.push_str("All active entries are standard loopback or block entries.\n");
7365                }
7366            }
7367
7368            out.push_str("\n=== Full file ===\n");
7369            for line in content.lines() {
7370                out.push_str(&format!("  {line}\n"));
7371            }
7372        }
7373        Err(e) => {
7374            out.push_str(&format!("Could not read hosts file: {e}\n"));
7375            if cfg!(target_os = "windows") {
7376                out.push_str(
7377                    "On Windows, run Hematite as Administrator if permission is denied.\n",
7378                );
7379            }
7380        }
7381    }
7382
7383    Ok(out.trim_end().to_string())
7384}
7385
7386// ── docker ────────────────────────────────────────────────────────────────────
7387
7388struct AuditFinding {
7389    finding: String,
7390    impact: String,
7391    fix: String,
7392}
7393
7394#[cfg(target_os = "windows")]
7395#[derive(Debug, Clone)]
7396struct WindowsPnpDevice {
7397    name: String,
7398    status: String,
7399    problem: Option<u64>,
7400    class_name: Option<String>,
7401    instance_id: Option<String>,
7402}
7403
7404#[cfg(target_os = "windows")]
7405#[derive(Debug, Clone)]
7406struct WindowsSoundDevice {
7407    name: String,
7408    status: String,
7409    manufacturer: Option<String>,
7410}
7411
7412struct DockerMountAudit {
7413    mount_type: String,
7414    source: Option<String>,
7415    destination: String,
7416    name: Option<String>,
7417    read_write: Option<bool>,
7418    driver: Option<String>,
7419    exists_on_host: Option<bool>,
7420}
7421
7422struct DockerContainerAudit {
7423    name: String,
7424    image: String,
7425    status: String,
7426    mounts: Vec<DockerMountAudit>,
7427}
7428
7429struct DockerVolumeAudit {
7430    name: String,
7431    driver: String,
7432    mountpoint: Option<String>,
7433    scope: Option<String>,
7434}
7435
7436#[cfg(target_os = "windows")]
7437struct WslDistroAudit {
7438    name: String,
7439    state: String,
7440    version: String,
7441}
7442
7443#[cfg(target_os = "windows")]
7444struct WslRootUsage {
7445    total_kb: u64,
7446    used_kb: u64,
7447    avail_kb: u64,
7448    use_percent: String,
7449    mnt_c_present: Option<bool>,
7450}
7451
7452fn docker_engine_version() -> Result<String, String> {
7453    let version_output = Command::new("docker")
7454        .args(["version", "--format", "{{.Server.Version}}"])
7455        .output();
7456
7457    match version_output {
7458        Err(_) => Err(
7459            "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
7460        ),
7461        Ok(o) if !o.status.success() => {
7462            let stderr = String::from_utf8_lossy(&o.stderr);
7463            if stderr.contains("cannot connect")
7464                || stderr.contains("Is the docker daemon running")
7465                || stderr.contains("pipe")
7466                || stderr.contains("socket")
7467            {
7468                Err(
7469                    "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
7470                )
7471            } else {
7472                Err(format!("Docker: error - {}", stderr.trim()))
7473            }
7474        }
7475        Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
7476    }
7477}
7478
7479fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
7480    let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
7481        return Vec::new();
7482    };
7483    let Value::Array(entries) = value else {
7484        return Vec::new();
7485    };
7486
7487    let mut mounts = Vec::new();
7488    for entry in entries {
7489        let mount_type = entry
7490            .get("Type")
7491            .and_then(|v| v.as_str())
7492            .unwrap_or("unknown")
7493            .to_string();
7494        let source = entry
7495            .get("Source")
7496            .and_then(|v| v.as_str())
7497            .map(|v| v.to_string());
7498        let destination = entry
7499            .get("Destination")
7500            .and_then(|v| v.as_str())
7501            .unwrap_or("?")
7502            .to_string();
7503        let name = entry
7504            .get("Name")
7505            .and_then(|v| v.as_str())
7506            .map(|v| v.to_string());
7507        let read_write = entry.get("RW").and_then(|v| v.as_bool());
7508        let driver = entry
7509            .get("Driver")
7510            .and_then(|v| v.as_str())
7511            .map(|v| v.to_string());
7512        let exists_on_host = if mount_type == "bind" {
7513            source.as_deref().map(|path| Path::new(path).exists())
7514        } else {
7515            None
7516        };
7517        mounts.push(DockerMountAudit {
7518            mount_type,
7519            source,
7520            destination,
7521            name,
7522            read_write,
7523            driver,
7524            exists_on_host,
7525        });
7526    }
7527
7528    mounts
7529}
7530
7531fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
7532    let mut audit = DockerVolumeAudit {
7533        name: name.to_string(),
7534        driver: "unknown".to_string(),
7535        mountpoint: None,
7536        scope: None,
7537    };
7538
7539    if let Ok(output) = Command::new("docker")
7540        .args(["volume", "inspect", name, "--format", "{{json .}}"])
7541        .output()
7542    {
7543        if output.status.success() {
7544            if let Ok(value) =
7545                serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
7546            {
7547                audit.driver = value
7548                    .get("Driver")
7549                    .and_then(|v| v.as_str())
7550                    .unwrap_or("unknown")
7551                    .to_string();
7552                audit.mountpoint = value
7553                    .get("Mountpoint")
7554                    .and_then(|v| v.as_str())
7555                    .map(|v| v.to_string());
7556                audit.scope = value
7557                    .get("Scope")
7558                    .and_then(|v| v.as_str())
7559                    .map(|v| v.to_string());
7560            }
7561        }
7562    }
7563
7564    audit
7565}
7566
7567#[cfg(target_os = "windows")]
7568fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
7569    let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
7570    for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
7571        let path = local_app_data
7572            .join("Docker")
7573            .join("wsl")
7574            .join("disk")
7575            .join(file_name);
7576        if let Ok(metadata) = fs::metadata(&path) {
7577            return Some((path, metadata.len()));
7578        }
7579    }
7580    None
7581}
7582
7583#[cfg(target_os = "windows")]
7584fn clean_wsl_text(raw: &[u8]) -> String {
7585    String::from_utf8_lossy(raw)
7586        .chars()
7587        .filter(|c| *c != '\0')
7588        .collect()
7589}
7590
7591#[cfg(target_os = "windows")]
7592fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
7593    let mut distros = Vec::new();
7594    for line in raw.lines() {
7595        let trimmed = line.trim();
7596        if trimmed.is_empty()
7597            || trimmed.to_uppercase().starts_with("NAME")
7598            || trimmed.starts_with("---")
7599        {
7600            continue;
7601        }
7602        let normalized = trimmed.trim_start_matches('*').trim();
7603        let cols: Vec<&str> = normalized.split_whitespace().collect();
7604        if cols.len() < 3 {
7605            continue;
7606        }
7607        let version = cols[cols.len() - 1].to_string();
7608        let state = cols[cols.len() - 2].to_string();
7609        let name = cols[..cols.len() - 2].join(" ");
7610        if !name.is_empty() {
7611            distros.push(WslDistroAudit {
7612                name,
7613                state,
7614                version,
7615            });
7616        }
7617    }
7618    distros
7619}
7620
7621#[cfg(target_os = "windows")]
7622fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
7623    let output = Command::new("wsl")
7624        .args([
7625            "-d",
7626            distro_name,
7627            "--",
7628            "sh",
7629            "-lc",
7630            "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
7631        ])
7632        .output()
7633        .ok()?;
7634    if !output.status.success() {
7635        return None;
7636    }
7637
7638    let text = clean_wsl_text(&output.stdout);
7639    let mut total_kb = 0;
7640    let mut used_kb = 0;
7641    let mut avail_kb = 0;
7642    let mut use_percent = String::from("unknown");
7643    let mut mnt_c_present = None;
7644
7645    for line in text.lines() {
7646        let trimmed = line.trim();
7647        if trimmed.starts_with("__MNTC__:") {
7648            mnt_c_present = Some(trimmed.ends_with("ok"));
7649            continue;
7650        }
7651        let cols: Vec<&str> = trimmed.split_whitespace().collect();
7652        if cols.len() >= 6 {
7653            total_kb = cols[1].parse::<u64>().unwrap_or(0);
7654            used_kb = cols[2].parse::<u64>().unwrap_or(0);
7655            avail_kb = cols[3].parse::<u64>().unwrap_or(0);
7656            use_percent = cols[4].to_string();
7657        }
7658    }
7659
7660    Some(WslRootUsage {
7661        total_kb,
7662        used_kb,
7663        avail_kb,
7664        use_percent,
7665        mnt_c_present,
7666    })
7667}
7668
7669#[cfg(target_os = "windows")]
7670fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
7671    let mut vhds = Vec::new();
7672    let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
7673        return vhds;
7674    };
7675    let packages_dir = local_app_data.join("Packages");
7676    let Ok(entries) = fs::read_dir(packages_dir) else {
7677        return vhds;
7678    };
7679
7680    for entry in entries.flatten() {
7681        let path = entry.path().join("LocalState").join("ext4.vhdx");
7682        if let Ok(metadata) = fs::metadata(&path) {
7683            vhds.push((path, metadata.len()));
7684        }
7685    }
7686    vhds.sort_by(|a, b| b.1.cmp(&a.1));
7687    vhds
7688}
7689
7690fn inspect_docker(max_entries: usize) -> Result<String, String> {
7691    let mut out = String::from("Host inspection: docker\n\n");
7692    let n = max_entries.clamp(5, 25);
7693
7694    let version_output = Command::new("docker")
7695        .args(["version", "--format", "{{.Server.Version}}"])
7696        .output();
7697
7698    match version_output {
7699        Err(_) => {
7700            out.push_str("Docker: not found on PATH.\n");
7701            out.push_str(
7702                "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
7703            );
7704            return Ok(out.trim_end().to_string());
7705        }
7706        Ok(o) if !o.status.success() => {
7707            let stderr = String::from_utf8_lossy(&o.stderr);
7708            if stderr.contains("cannot connect")
7709                || stderr.contains("Is the docker daemon running")
7710                || stderr.contains("pipe")
7711                || stderr.contains("socket")
7712            {
7713                out.push_str("Docker: installed but daemon is NOT running.\n");
7714                out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
7715            } else {
7716                out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
7717            }
7718            return Ok(out.trim_end().to_string());
7719        }
7720        Ok(o) => {
7721            let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
7722            out.push_str(&format!("Docker Engine: {version}\n"));
7723        }
7724    }
7725
7726    if let Ok(o) = Command::new("docker")
7727        .args([
7728            "info",
7729            "--format",
7730            "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
7731        ])
7732        .output()
7733    {
7734        let info = String::from_utf8_lossy(&o.stdout);
7735        for line in info.lines() {
7736            let t = line.trim();
7737            if !t.is_empty() {
7738                out.push_str(&format!("  {t}\n"));
7739            }
7740        }
7741        out.push('\n');
7742    }
7743
7744    if let Ok(o) = Command::new("docker")
7745        .args([
7746            "ps",
7747            "--format",
7748            "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
7749        ])
7750        .output()
7751    {
7752        let raw = String::from_utf8_lossy(&o.stdout);
7753        let lines: Vec<&str> = raw.lines().collect();
7754        if lines.len() <= 1 {
7755            out.push_str("Running containers: none\n\n");
7756        } else {
7757            out.push_str(&format!(
7758                "=== Running containers ({}) ===\n",
7759                lines.len().saturating_sub(1)
7760            ));
7761            for line in lines.iter().take(n + 1) {
7762                out.push_str(&format!("  {line}\n"));
7763            }
7764            if lines.len() > n + 1 {
7765                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
7766            }
7767            out.push('\n');
7768        }
7769    }
7770
7771    if let Ok(o) = Command::new("docker")
7772        .args([
7773            "images",
7774            "--format",
7775            "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
7776        ])
7777        .output()
7778    {
7779        let raw = String::from_utf8_lossy(&o.stdout);
7780        let lines: Vec<&str> = raw.lines().collect();
7781        if lines.len() > 1 {
7782            out.push_str(&format!(
7783                "=== Local images ({}) ===\n",
7784                lines.len().saturating_sub(1)
7785            ));
7786            for line in lines.iter().take(n + 1) {
7787                out.push_str(&format!("  {line}\n"));
7788            }
7789            if lines.len() > n + 1 {
7790                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
7791            }
7792            out.push('\n');
7793        }
7794    }
7795
7796    if let Ok(o) = Command::new("docker")
7797        .args([
7798            "compose",
7799            "ls",
7800            "--format",
7801            "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
7802        ])
7803        .output()
7804    {
7805        let raw = String::from_utf8_lossy(&o.stdout);
7806        let lines: Vec<&str> = raw.lines().collect();
7807        if lines.len() > 1 {
7808            out.push_str(&format!(
7809                "=== Compose projects ({}) ===\n",
7810                lines.len().saturating_sub(1)
7811            ));
7812            for line in lines.iter().take(n + 1) {
7813                out.push_str(&format!("  {line}\n"));
7814            }
7815            out.push('\n');
7816        }
7817    }
7818
7819    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7820        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7821        if !ctx.is_empty() {
7822            out.push_str(&format!("Active context: {ctx}\n"));
7823        }
7824    }
7825
7826    Ok(out.trim_end().to_string())
7827}
7828
7829// ── wsl ───────────────────────────────────────────────────────────────────────
7830
7831fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
7832    let mut out = String::from("Host inspection: docker_filesystems\n\n");
7833    let n = max_entries.clamp(3, 12);
7834
7835    match docker_engine_version() {
7836        Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
7837        Err(message) => {
7838            out.push_str(&message);
7839            return Ok(out.trim_end().to_string());
7840        }
7841    }
7842
7843    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7844        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7845        if !ctx.is_empty() {
7846            out.push_str(&format!("Active context: {ctx}\n"));
7847        }
7848    }
7849    out.push('\n');
7850
7851    let mut containers = Vec::new();
7852    if let Ok(o) = Command::new("docker")
7853        .args([
7854            "ps",
7855            "-a",
7856            "--format",
7857            "{{.Names}}\t{{.Image}}\t{{.Status}}",
7858        ])
7859        .output()
7860    {
7861        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7862            let cols: Vec<&str> = line.split('\t').collect();
7863            if cols.len() < 3 {
7864                continue;
7865            }
7866            let name = cols[0].trim().to_string();
7867            if name.is_empty() {
7868                continue;
7869            }
7870            let inspect_output = Command::new("docker")
7871                .args(["inspect", &name, "--format", "{{json .Mounts}}"])
7872                .output();
7873            let mounts = match inspect_output {
7874                Ok(result) if result.status.success() => {
7875                    parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
7876                }
7877                _ => Vec::new(),
7878            };
7879            containers.push(DockerContainerAudit {
7880                name,
7881                image: cols[1].trim().to_string(),
7882                status: cols[2].trim().to_string(),
7883                mounts,
7884            });
7885        }
7886    }
7887
7888    let mut volumes = Vec::new();
7889    if let Ok(o) = Command::new("docker")
7890        .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
7891        .output()
7892    {
7893        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7894            let cols: Vec<&str> = line.split('\t').collect();
7895            let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
7896                continue;
7897            };
7898            let mut audit = inspect_docker_volume(name);
7899            if audit.driver == "unknown" {
7900                audit.driver = cols
7901                    .get(1)
7902                    .map(|v| v.trim())
7903                    .filter(|v| !v.is_empty())
7904                    .unwrap_or("unknown")
7905                    .to_string();
7906            }
7907            volumes.push(audit);
7908        }
7909    }
7910
7911    let mut findings = Vec::new();
7912    for container in &containers {
7913        for mount in &container.mounts {
7914            if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
7915                let source = mount.source.as_deref().unwrap_or("<unknown>");
7916                findings.push(AuditFinding {
7917                    finding: format!(
7918                        "Container '{}' has a bind mount whose host source is missing: {} -> {}",
7919                        container.name, source, mount.destination
7920                    ),
7921                    impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
7922                    fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
7923                });
7924            }
7925        }
7926    }
7927
7928    #[cfg(target_os = "windows")]
7929    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
7930        if size_bytes >= 20 * 1024 * 1024 * 1024 {
7931            findings.push(AuditFinding {
7932                finding: format!(
7933                    "Docker Desktop disk image is large: {} at {}",
7934                    human_bytes(size_bytes),
7935                    path.display()
7936                ),
7937                impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
7938                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(),
7939            });
7940        }
7941    }
7942
7943    out.push_str("=== Findings ===\n");
7944    if findings.is_empty() {
7945        out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
7946        out.push_str("  Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
7947        out.push_str("  Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
7948    } else {
7949        for finding in &findings {
7950            out.push_str(&format!("- Finding: {}\n", finding.finding));
7951            out.push_str(&format!("  Impact: {}\n", finding.impact));
7952            out.push_str(&format!("  Fix: {}\n", finding.fix));
7953        }
7954    }
7955
7956    out.push_str("\n=== Container mount summary ===\n");
7957    if containers.is_empty() {
7958        out.push_str("- No containers found.\n");
7959    } else {
7960        for container in &containers {
7961            out.push_str(&format!(
7962                "- {} ({}) [{}]\n",
7963                container.name, container.image, container.status
7964            ));
7965            if container.mounts.is_empty() {
7966                out.push_str("  - no mounts reported\n");
7967                continue;
7968            }
7969            for mount in &container.mounts {
7970                let mut source = mount
7971                    .name
7972                    .clone()
7973                    .or_else(|| mount.source.clone())
7974                    .unwrap_or_else(|| "<unknown>".to_string());
7975                if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
7976                    source.push_str(" [missing]");
7977                }
7978                let mut extras = Vec::new();
7979                if let Some(rw) = mount.read_write {
7980                    extras.push(if rw { "rw" } else { "ro" }.to_string());
7981                }
7982                if let Some(driver) = &mount.driver {
7983                    extras.push(format!("driver={driver}"));
7984                }
7985                let extra_suffix = if extras.is_empty() {
7986                    String::new()
7987                } else {
7988                    format!(" ({})", extras.join(", "))
7989                };
7990                out.push_str(&format!(
7991                    "  - {}: {} -> {}{}\n",
7992                    mount.mount_type, source, mount.destination, extra_suffix
7993                ));
7994            }
7995        }
7996    }
7997
7998    out.push_str("\n=== Named volumes ===\n");
7999    if volumes.is_empty() {
8000        out.push_str("- No named volumes found.\n");
8001    } else {
8002        for volume in &volumes {
8003            let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
8004            if let Some(scope) = &volume.scope {
8005                detail.push_str(&format!(", scope: {scope}"));
8006            }
8007            if let Some(mountpoint) = &volume.mountpoint {
8008                detail.push_str(&format!(", mountpoint: {mountpoint}"));
8009            }
8010            out.push_str(&format!("{detail}\n"));
8011        }
8012    }
8013
8014    #[cfg(target_os = "windows")]
8015    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
8016        out.push_str("\n=== Docker Desktop disk ===\n");
8017        out.push_str(&format!(
8018            "- {} at {}\n",
8019            human_bytes(size_bytes),
8020            path.display()
8021        ));
8022    }
8023
8024    Ok(out.trim_end().to_string())
8025}
8026
8027fn inspect_wsl() -> Result<String, String> {
8028    let mut out = String::from("Host inspection: wsl\n\n");
8029
8030    #[cfg(target_os = "windows")]
8031    {
8032        if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
8033            let raw = String::from_utf8_lossy(&o.stdout);
8034            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8035            for line in cleaned.lines().take(4) {
8036                let t = line.trim();
8037                if !t.is_empty() {
8038                    out.push_str(&format!("  {t}\n"));
8039                }
8040            }
8041            out.push('\n');
8042        }
8043
8044        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8045        match list_output {
8046            Err(e) => {
8047                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8048                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8049            }
8050            Ok(o) if !o.status.success() => {
8051                let stderr = String::from_utf8_lossy(&o.stderr);
8052                let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
8053                out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
8054                out.push_str("Run: wsl --install\n");
8055            }
8056            Ok(o) => {
8057                let raw = String::from_utf8_lossy(&o.stdout);
8058                let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8059                let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
8060                let distro_lines: Vec<&str> = lines
8061                    .iter()
8062                    .filter(|l| {
8063                        let t = l.trim();
8064                        !t.is_empty()
8065                            && !t.to_uppercase().starts_with("NAME")
8066                            && !t.starts_with("---")
8067                    })
8068                    .copied()
8069                    .collect();
8070
8071                if distro_lines.is_empty() {
8072                    out.push_str("WSL: installed but no distributions found.\n");
8073                    out.push_str("Install a distro: wsl --install -d Ubuntu\n");
8074                } else {
8075                    out.push_str("=== WSL Distributions ===\n");
8076                    for line in &lines {
8077                        out.push_str(&format!("  {}\n", line.trim()));
8078                    }
8079                    out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
8080                }
8081            }
8082        }
8083
8084        if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
8085            let raw = String::from_utf8_lossy(&o.stdout);
8086            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
8087            let status_lines: Vec<&str> = cleaned
8088                .lines()
8089                .filter(|l| !l.trim().is_empty())
8090                .take(8)
8091                .collect();
8092            if !status_lines.is_empty() {
8093                out.push_str("\n=== WSL status ===\n");
8094                for line in status_lines {
8095                    out.push_str(&format!("  {}\n", line.trim()));
8096                }
8097            }
8098        }
8099    }
8100
8101    #[cfg(not(target_os = "windows"))]
8102    {
8103        out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
8104        out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
8105    }
8106
8107    Ok(out.trim_end().to_string())
8108}
8109
8110// ── ssh ───────────────────────────────────────────────────────────────────────
8111
8112fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
8113    let mut out = String::from("Host inspection: wsl_filesystems\n\n");
8114
8115    #[cfg(target_os = "windows")]
8116    {
8117        let n = max_entries.clamp(3, 12);
8118        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
8119        let distros = match list_output {
8120            Err(e) => {
8121                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
8122                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
8123                return Ok(out.trim_end().to_string());
8124            }
8125            Ok(o) if !o.status.success() => {
8126                let cleaned = clean_wsl_text(&o.stderr);
8127                out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
8128                out.push_str("Run: wsl --install\n");
8129                return Ok(out.trim_end().to_string());
8130            }
8131            Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
8132        };
8133
8134        out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
8135
8136        let vhdx_files = collect_wsl_vhdx_files();
8137        let mut findings = Vec::new();
8138        let mut live_usage = Vec::new();
8139
8140        for distro in distros.iter().take(n) {
8141            if distro.state.eq_ignore_ascii_case("Running") {
8142                if let Some(usage) = wsl_root_usage(&distro.name) {
8143                    if let Some(false) = usage.mnt_c_present {
8144                        findings.push(AuditFinding {
8145                            finding: format!(
8146                                "Distro '{}' is running without /mnt/c available",
8147                                distro.name
8148                            ),
8149                            impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
8150                            fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
8151                        });
8152                    }
8153
8154                    let percent_num = usage
8155                        .use_percent
8156                        .trim_end_matches('%')
8157                        .parse::<u32>()
8158                        .unwrap_or(0);
8159                    if percent_num >= 85 {
8160                        findings.push(AuditFinding {
8161                            finding: format!(
8162                                "Distro '{}' root filesystem is {} full",
8163                                distro.name, usage.use_percent
8164                            ),
8165                            impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8166                            fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8167                        });
8168                    }
8169                    live_usage.push((distro.name.clone(), usage));
8170                }
8171            }
8172        }
8173
8174        for (path, size_bytes) in vhdx_files.iter().take(n) {
8175            if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8176                findings.push(AuditFinding {
8177                    finding: format!(
8178                        "Host-side WSL disk image is large: {} at {}",
8179                        human_bytes(*size_bytes),
8180                        path.display()
8181                    ),
8182                    impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8183                    fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8184                });
8185            }
8186        }
8187
8188        out.push_str("=== Findings ===\n");
8189        if findings.is_empty() {
8190            out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8191            out.push_str("  Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8192            out.push_str("  Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8193        } else {
8194            for finding in &findings {
8195                out.push_str(&format!("- Finding: {}\n", finding.finding));
8196                out.push_str(&format!("  Impact: {}\n", finding.impact));
8197                out.push_str(&format!("  Fix: {}\n", finding.fix));
8198            }
8199        }
8200
8201        out.push_str("\n=== Distro bridge and root usage ===\n");
8202        if distros.is_empty() {
8203            out.push_str("- No WSL distributions found.\n");
8204        } else {
8205            for distro in distros.iter().take(n) {
8206                out.push_str(&format!(
8207                    "- {} [state: {}, version: {}]\n",
8208                    distro.name, distro.state, distro.version
8209                ));
8210                if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8211                    out.push_str(&format!(
8212                        "  - rootfs: {} used / {} total ({}), free: {}\n",
8213                        human_bytes(usage.used_kb * 1024),
8214                        human_bytes(usage.total_kb * 1024),
8215                        usage.use_percent,
8216                        human_bytes(usage.avail_kb * 1024)
8217                    ));
8218                    match usage.mnt_c_present {
8219                        Some(true) => out.push_str("  - /mnt/c bridge: present\n"),
8220                        Some(false) => out.push_str("  - /mnt/c bridge: missing\n"),
8221                        None => out.push_str("  - /mnt/c bridge: unknown\n"),
8222                    }
8223                } else if distro.state.eq_ignore_ascii_case("Running") {
8224                    out.push_str("  - live rootfs check: unavailable\n");
8225                } else {
8226                    out.push_str(
8227                        "  - live rootfs check: skipped to avoid starting a stopped distro\n",
8228                    );
8229                }
8230            }
8231        }
8232
8233        out.push_str("\n=== Host-side VHDX files ===\n");
8234        if vhdx_files.is_empty() {
8235            out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8236        } else {
8237            for (path, size_bytes) in vhdx_files.iter().take(n) {
8238                out.push_str(&format!(
8239                    "- {} at {}\n",
8240                    human_bytes(*size_bytes),
8241                    path.display()
8242                ));
8243            }
8244        }
8245    }
8246
8247    #[cfg(not(target_os = "windows"))]
8248    {
8249        let _ = max_entries;
8250        out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8251        out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8252    }
8253
8254    Ok(out.trim_end().to_string())
8255}
8256
8257fn dirs_home() -> Option<PathBuf> {
8258    std::env::var("HOME")
8259        .ok()
8260        .map(PathBuf::from)
8261        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8262}
8263
8264fn inspect_ssh() -> Result<String, String> {
8265    let mut out = String::from("Host inspection: ssh\n\n");
8266
8267    if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8268        let ver = if o.stdout.is_empty() {
8269            String::from_utf8_lossy(&o.stderr).trim().to_string()
8270        } else {
8271            String::from_utf8_lossy(&o.stdout).trim().to_string()
8272        };
8273        if !ver.is_empty() {
8274            out.push_str(&format!("SSH client: {ver}\n"));
8275        }
8276    } else {
8277        out.push_str("SSH client: not found on PATH.\n");
8278    }
8279
8280    #[cfg(target_os = "windows")]
8281    {
8282        let script = r#"
8283$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8284if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8285else { "SSHD:not_installed" }
8286"#;
8287        if let Ok(o) = Command::new("powershell")
8288            .args(["-NoProfile", "-Command", script])
8289            .output()
8290        {
8291            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8292            if text.contains("not_installed") {
8293                out.push_str("SSH server (sshd): not installed\n");
8294            } else {
8295                out.push_str(&format!(
8296                    "SSH server (sshd): {}\n",
8297                    text.trim_start_matches("SSHD:")
8298                ));
8299            }
8300        }
8301    }
8302
8303    #[cfg(not(target_os = "windows"))]
8304    {
8305        if let Ok(o) = Command::new("systemctl")
8306            .args(["is-active", "sshd"])
8307            .output()
8308        {
8309            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8310            out.push_str(&format!("SSH server (sshd): {status}\n"));
8311        } else if let Ok(o) = Command::new("systemctl")
8312            .args(["is-active", "ssh"])
8313            .output()
8314        {
8315            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8316            out.push_str(&format!("SSH server (ssh): {status}\n"));
8317        }
8318    }
8319
8320    out.push('\n');
8321
8322    if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8323        if ssh_dir.exists() {
8324            out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8325
8326            let kh = ssh_dir.join("known_hosts");
8327            if kh.exists() {
8328                let count = fs::read_to_string(&kh)
8329                    .map(|c| {
8330                        c.lines()
8331                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8332                            .count()
8333                    })
8334                    .unwrap_or(0);
8335                out.push_str(&format!("  known_hosts: {count} entries\n"));
8336            } else {
8337                out.push_str("  known_hosts: not present\n");
8338            }
8339
8340            let ak = ssh_dir.join("authorized_keys");
8341            if ak.exists() {
8342                let count = fs::read_to_string(&ak)
8343                    .map(|c| {
8344                        c.lines()
8345                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8346                            .count()
8347                    })
8348                    .unwrap_or(0);
8349                out.push_str(&format!("  authorized_keys: {count} public keys\n"));
8350            } else {
8351                out.push_str("  authorized_keys: not present\n");
8352            }
8353
8354            let key_names = [
8355                "id_rsa",
8356                "id_ed25519",
8357                "id_ecdsa",
8358                "id_dsa",
8359                "id_ecdsa_sk",
8360                "id_ed25519_sk",
8361            ];
8362            let found_keys: Vec<&str> = key_names
8363                .iter()
8364                .filter(|k| ssh_dir.join(k).exists())
8365                .copied()
8366                .collect();
8367            if !found_keys.is_empty() {
8368                out.push_str(&format!("  Private keys: {}\n", found_keys.join(", ")));
8369            } else {
8370                out.push_str("  Private keys: none found\n");
8371            }
8372
8373            let config_path = ssh_dir.join("config");
8374            if config_path.exists() {
8375                out.push_str("\n=== SSH config hosts ===\n");
8376                match fs::read_to_string(&config_path) {
8377                    Ok(content) => {
8378                        let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8379                        let mut current: Option<(String, Vec<String>)> = None;
8380                        for line in content.lines() {
8381                            let t = line.trim();
8382                            if t.is_empty() || t.starts_with('#') {
8383                                continue;
8384                            }
8385                            if let Some(host) = t.strip_prefix("Host ") {
8386                                if let Some(prev) = current.take() {
8387                                    hosts.push(prev);
8388                                }
8389                                current = Some((host.trim().to_string(), Vec::new()));
8390                            } else if let Some((_, ref mut details)) = current {
8391                                let tu = t.to_uppercase();
8392                                if tu.starts_with("HOSTNAME ")
8393                                    || tu.starts_with("USER ")
8394                                    || tu.starts_with("PORT ")
8395                                    || tu.starts_with("IDENTITYFILE ")
8396                                {
8397                                    details.push(t.to_string());
8398                                }
8399                            }
8400                        }
8401                        if let Some(prev) = current {
8402                            hosts.push(prev);
8403                        }
8404
8405                        if hosts.is_empty() {
8406                            out.push_str("  No Host entries found.\n");
8407                        } else {
8408                            for (h, details) in &hosts {
8409                                if details.is_empty() {
8410                                    out.push_str(&format!("  Host {h}\n"));
8411                                } else {
8412                                    out.push_str(&format!(
8413                                        "  Host {h}  [{}]\n",
8414                                        details.join(", ")
8415                                    ));
8416                                }
8417                            }
8418                            out.push_str(&format!("\n  Total configured hosts: {}\n", hosts.len()));
8419                        }
8420                    }
8421                    Err(e) => out.push_str(&format!("  Could not read config: {e}\n")),
8422                }
8423            } else {
8424                out.push_str("  SSH config: not present\n");
8425            }
8426        } else {
8427            out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
8428        }
8429    }
8430
8431    Ok(out.trim_end().to_string())
8432}
8433
8434// ── installed_software ────────────────────────────────────────────────────────
8435
8436fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
8437    let mut out = String::from("Host inspection: installed_software\n\n");
8438    let n = max_entries.clamp(10, 50);
8439
8440    #[cfg(target_os = "windows")]
8441    {
8442        let winget_out = Command::new("winget")
8443            .args(["list", "--accept-source-agreements"])
8444            .output();
8445
8446        if let Ok(o) = winget_out {
8447            if o.status.success() {
8448                let raw = String::from_utf8_lossy(&o.stdout);
8449                let mut header_done = false;
8450                let mut packages: Vec<&str> = Vec::new();
8451                for line in raw.lines() {
8452                    let t = line.trim();
8453                    if t.starts_with("---") {
8454                        header_done = true;
8455                        continue;
8456                    }
8457                    if header_done && !t.is_empty() {
8458                        packages.push(line);
8459                    }
8460                }
8461                let total = packages.len();
8462                out.push_str(&format!(
8463                    "=== Installed software via winget ({total} packages) ===\n\n"
8464                ));
8465                for line in packages.iter().take(n) {
8466                    out.push_str(&format!("  {line}\n"));
8467                }
8468                if total > n {
8469                    out.push_str(&format!("\n  ... and {} more packages\n", total - n));
8470                }
8471                out.push_str("\nFor full list: winget list\n");
8472                return Ok(out.trim_end().to_string());
8473            }
8474        }
8475
8476        // Fallback: registry scan
8477        let script = format!(
8478            r#"
8479$apps = @()
8480$reg_paths = @(
8481    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
8482    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
8483    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
8484)
8485foreach ($p in $reg_paths) {{
8486    try {{
8487        $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
8488            Where-Object {{ $_.DisplayName }} |
8489            Select-Object DisplayName, DisplayVersion, Publisher
8490    }} catch {{}}
8491}}
8492$sorted = $apps | Sort-Object DisplayName -Unique
8493"TOTAL:" + $sorted.Count
8494$sorted | Select-Object -First {n} | ForEach-Object {{
8495    $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
8496}}
8497"#
8498        );
8499        if let Ok(o) = Command::new("powershell")
8500            .args(["-NoProfile", "-Command", &script])
8501            .output()
8502        {
8503            let raw = String::from_utf8_lossy(&o.stdout);
8504            out.push_str("=== Installed software (registry scan) ===\n");
8505            out.push_str(&format!("  {:<50} {:<18} Publisher\n", "Name", "Version"));
8506            out.push_str(&format!("  {}\n", "-".repeat(90)));
8507            for line in raw.lines() {
8508                if let Some(rest) = line.strip_prefix("TOTAL:") {
8509                    let total: usize = rest.trim().parse().unwrap_or(0);
8510                    out.push_str(&format!("  (Total: {total}, showing first {n})\n\n"));
8511                } else if !line.trim().is_empty() {
8512                    let parts: Vec<&str> = line.splitn(3, '|').collect();
8513                    let name = parts.first().map(|s| s.trim()).unwrap_or("");
8514                    let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
8515                    let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
8516                    out.push_str(&format!("  {:<50} {:<18} {pub_}\n", name, ver));
8517                }
8518            }
8519        } else {
8520            out.push_str(
8521                "Could not query installed software (winget and registry scan both failed).\n",
8522            );
8523        }
8524    }
8525
8526    #[cfg(target_os = "linux")]
8527    {
8528        let mut found = false;
8529        if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
8530            if o.status.success() {
8531                let raw = String::from_utf8_lossy(&o.stdout);
8532                let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
8533                let total = installed.len();
8534                out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
8535                for line in installed.iter().take(n) {
8536                    out.push_str(&format!("  {}\n", line.trim()));
8537                }
8538                if total > n {
8539                    out.push_str(&format!("  ... and {} more\n", total - n));
8540                }
8541                out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
8542                found = true;
8543            }
8544        }
8545        if !found {
8546            if let Ok(o) = Command::new("rpm")
8547                .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
8548                .output()
8549            {
8550                if o.status.success() {
8551                    let raw = String::from_utf8_lossy(&o.stdout);
8552                    let lines: Vec<&str> = raw.lines().collect();
8553                    let total = lines.len();
8554                    out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
8555                    for line in lines.iter().take(n) {
8556                        out.push_str(&format!("  {line}\n"));
8557                    }
8558                    if total > n {
8559                        out.push_str(&format!("  ... and {} more\n", total - n));
8560                    }
8561                    found = true;
8562                }
8563            }
8564        }
8565        if !found {
8566            if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
8567                if o.status.success() {
8568                    let raw = String::from_utf8_lossy(&o.stdout);
8569                    let lines: Vec<&str> = raw.lines().collect();
8570                    let total = lines.len();
8571                    out.push_str(&format!(
8572                        "=== Installed packages via pacman ({total}) ===\n"
8573                    ));
8574                    for line in lines.iter().take(n) {
8575                        out.push_str(&format!("  {line}\n"));
8576                    }
8577                    if total > n {
8578                        out.push_str(&format!("  ... and {} more\n", total - n));
8579                    }
8580                    found = true;
8581                }
8582            }
8583        }
8584        if !found {
8585            out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
8586        }
8587    }
8588
8589    #[cfg(target_os = "macos")]
8590    {
8591        if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
8592            if o.status.success() {
8593                let raw = String::from_utf8_lossy(&o.stdout);
8594                let lines: Vec<&str> = raw.lines().collect();
8595                let total = lines.len();
8596                out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
8597                for line in lines.iter().take(n) {
8598                    out.push_str(&format!("  {line}\n"));
8599                }
8600                if total > n {
8601                    out.push_str(&format!("  ... and {} more\n", total - n));
8602                }
8603                out.push_str("\nFor full list: brew list --versions\n");
8604            }
8605        } else {
8606            out.push_str("Homebrew not found.\n");
8607        }
8608        if let Ok(o) = Command::new("mas").args(["list"]).output() {
8609            if o.status.success() {
8610                let raw = String::from_utf8_lossy(&o.stdout);
8611                let lines: Vec<&str> = raw.lines().collect();
8612                out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
8613                for line in lines.iter().take(n) {
8614                    out.push_str(&format!("  {line}\n"));
8615                }
8616            }
8617        }
8618    }
8619
8620    Ok(out.trim_end().to_string())
8621}
8622
8623// ── git_config ────────────────────────────────────────────────────────────────
8624
8625fn inspect_git_config() -> Result<String, String> {
8626    let mut out = String::from("Host inspection: git_config\n\n");
8627
8628    if let Ok(o) = Command::new("git").args(["--version"]).output() {
8629        let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
8630        out.push_str(&format!("Git: {ver}\n\n"));
8631    } else {
8632        out.push_str("Git: not found on PATH.\n");
8633        return Ok(out.trim_end().to_string());
8634    }
8635
8636    if let Ok(o) = Command::new("git")
8637        .args(["config", "--global", "--list"])
8638        .output()
8639    {
8640        if o.status.success() {
8641            let raw = String::from_utf8_lossy(&o.stdout);
8642            let mut pairs: Vec<(String, String)> = raw
8643                .lines()
8644                .filter_map(|l| {
8645                    let mut parts = l.splitn(2, '=');
8646                    let k = parts.next()?.trim().to_string();
8647                    let v = parts.next().unwrap_or("").trim().to_string();
8648                    Some((k, v))
8649                })
8650                .collect();
8651            pairs.sort_by(|a, b| a.0.cmp(&b.0));
8652
8653            out.push_str("=== Global git config ===\n");
8654
8655            let sections: &[(&str, &[&str])] = &[
8656                ("Identity", &["user.name", "user.email", "user.signingkey"]),
8657                (
8658                    "Core",
8659                    &[
8660                        "core.editor",
8661                        "core.autocrlf",
8662                        "core.eol",
8663                        "core.ignorecase",
8664                        "core.filemode",
8665                    ],
8666                ),
8667                (
8668                    "Commit/Signing",
8669                    &[
8670                        "commit.gpgsign",
8671                        "tag.gpgsign",
8672                        "gpg.format",
8673                        "gpg.ssh.allowedsignersfile",
8674                    ],
8675                ),
8676                (
8677                    "Push/Pull",
8678                    &[
8679                        "push.default",
8680                        "push.autosetupremote",
8681                        "pull.rebase",
8682                        "pull.ff",
8683                    ],
8684                ),
8685                ("Credential", &["credential.helper"]),
8686                ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
8687            ];
8688
8689            let mut shown_keys: HashSet<String> = HashSet::new();
8690            for (section, keys) in sections {
8691                let mut section_lines: Vec<String> = Vec::new();
8692                for key in *keys {
8693                    if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
8694                        section_lines.push(format!("  {k} = {v}"));
8695                        shown_keys.insert(k.clone());
8696                    }
8697                }
8698                if !section_lines.is_empty() {
8699                    out.push_str(&format!("\n[{section}]\n"));
8700                    for line in section_lines {
8701                        out.push_str(&format!("{line}\n"));
8702                    }
8703                }
8704            }
8705
8706            let other: Vec<&(String, String)> = pairs
8707                .iter()
8708                .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
8709                .collect();
8710            if !other.is_empty() {
8711                out.push_str("\n[Other]\n");
8712                for (k, v) in other.iter().take(20) {
8713                    out.push_str(&format!("  {k} = {v}\n"));
8714                }
8715                if other.len() > 20 {
8716                    out.push_str(&format!("  ... and {} more\n", other.len() - 20));
8717                }
8718            }
8719
8720            out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
8721        } else {
8722            out.push_str("No global git config found.\n");
8723            out.push_str("Set up with:\n");
8724            out.push_str("  git config --global user.name \"Your Name\"\n");
8725            out.push_str("  git config --global user.email \"you@example.com\"\n");
8726        }
8727    }
8728
8729    if let Ok(o) = Command::new("git")
8730        .args(["config", "--local", "--list"])
8731        .output()
8732    {
8733        if o.status.success() {
8734            let raw = String::from_utf8_lossy(&o.stdout);
8735            let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8736            if !lines.is_empty() {
8737                out.push_str(&format!(
8738                    "\n=== Local repo config ({} keys) ===\n",
8739                    lines.len()
8740                ));
8741                for line in lines.iter().take(15) {
8742                    out.push_str(&format!("  {line}\n"));
8743                }
8744                if lines.len() > 15 {
8745                    out.push_str(&format!("  ... and {} more\n", lines.len() - 15));
8746                }
8747            }
8748        }
8749    }
8750
8751    if let Ok(o) = Command::new("git")
8752        .args(["config", "--global", "--get-regexp", r"alias\."])
8753        .output()
8754    {
8755        if o.status.success() {
8756            let raw = String::from_utf8_lossy(&o.stdout);
8757            let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8758            if !aliases.is_empty() {
8759                out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
8760                for a in aliases.iter().take(20) {
8761                    out.push_str(&format!("  {a}\n"));
8762                }
8763                if aliases.len() > 20 {
8764                    out.push_str(&format!("  ... and {} more\n", aliases.len() - 20));
8765                }
8766            }
8767        }
8768    }
8769
8770    Ok(out.trim_end().to_string())
8771}
8772
8773// ── databases ─────────────────────────────────────────────────────────────────
8774
8775fn inspect_databases() -> Result<String, String> {
8776    let mut out = String::from("Host inspection: databases\n\n");
8777    out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
8778
8779    struct DbEngine {
8780        name: &'static str,
8781        service_names: &'static [&'static str],
8782        default_port: u16,
8783        cli_name: &'static str,
8784        cli_version_args: &'static [&'static str],
8785    }
8786
8787    let engines: &[DbEngine] = &[
8788        DbEngine {
8789            name: "PostgreSQL",
8790            service_names: &[
8791                "postgresql",
8792                "postgresql-x64-14",
8793                "postgresql-x64-15",
8794                "postgresql-x64-16",
8795                "postgresql-x64-17",
8796            ],
8797
8798            default_port: 5432,
8799            cli_name: "psql",
8800            cli_version_args: &["--version"],
8801        },
8802        DbEngine {
8803            name: "MySQL",
8804            service_names: &["mysql", "mysql80", "mysql57"],
8805
8806            default_port: 3306,
8807            cli_name: "mysql",
8808            cli_version_args: &["--version"],
8809        },
8810        DbEngine {
8811            name: "MariaDB",
8812            service_names: &["mariadb", "mariadb.exe"],
8813
8814            default_port: 3306,
8815            cli_name: "mariadb",
8816            cli_version_args: &["--version"],
8817        },
8818        DbEngine {
8819            name: "MongoDB",
8820            service_names: &["mongodb", "mongod"],
8821
8822            default_port: 27017,
8823            cli_name: "mongod",
8824            cli_version_args: &["--version"],
8825        },
8826        DbEngine {
8827            name: "Redis",
8828            service_names: &["redis", "redis-server"],
8829
8830            default_port: 6379,
8831            cli_name: "redis-server",
8832            cli_version_args: &["--version"],
8833        },
8834        DbEngine {
8835            name: "SQL Server",
8836            service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
8837
8838            default_port: 1433,
8839            cli_name: "sqlcmd",
8840            cli_version_args: &["-?"],
8841        },
8842        DbEngine {
8843            name: "SQLite",
8844            service_names: &[], // no service — file-based
8845
8846            default_port: 0, // no port — file-based
8847            cli_name: "sqlite3",
8848            cli_version_args: &["--version"],
8849        },
8850        DbEngine {
8851            name: "CouchDB",
8852            service_names: &["couchdb", "apache-couchdb"],
8853
8854            default_port: 5984,
8855            cli_name: "couchdb",
8856            cli_version_args: &["--version"],
8857        },
8858        DbEngine {
8859            name: "Cassandra",
8860            service_names: &["cassandra"],
8861
8862            default_port: 9042,
8863            cli_name: "cqlsh",
8864            cli_version_args: &["--version"],
8865        },
8866        DbEngine {
8867            name: "Elasticsearch",
8868            service_names: &["elasticsearch-service-x64", "elasticsearch"],
8869
8870            default_port: 9200,
8871            cli_name: "elasticsearch",
8872            cli_version_args: &["--version"],
8873        },
8874    ];
8875
8876    // Helper: check if port is listening
8877    fn port_listening(port: u16) -> bool {
8878        if port == 0 {
8879            return false;
8880        }
8881        // Use netstat-style check via connecting
8882        std::net::TcpStream::connect_timeout(
8883            &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8884            std::time::Duration::from_millis(150),
8885        )
8886        .is_ok()
8887    }
8888
8889    let mut found_any = false;
8890
8891    for engine in engines {
8892        let mut status_parts: Vec<String> = Vec::new();
8893        let mut detected = false;
8894
8895        // 1. CLI version check (fastest — works cross-platform)
8896        let version = Command::new(engine.cli_name)
8897            .args(engine.cli_version_args)
8898            .output()
8899            .ok()
8900            .and_then(|o| {
8901                let combined = if o.stdout.is_empty() {
8902                    String::from_utf8_lossy(&o.stderr).trim().to_string()
8903                } else {
8904                    String::from_utf8_lossy(&o.stdout).trim().to_string()
8905                };
8906                // Take just the first line
8907                combined.lines().next().map(|l| l.trim().to_string())
8908            });
8909
8910        if let Some(ref ver) = version {
8911            if !ver.is_empty() {
8912                status_parts.push(format!("version: {ver}"));
8913                detected = true;
8914            }
8915        }
8916
8917        // 2. Port check
8918        if engine.default_port > 0 && port_listening(engine.default_port) {
8919            status_parts.push(format!("listening on :{}", engine.default_port));
8920            detected = true;
8921        } else if engine.default_port > 0 && detected {
8922            status_parts.push(format!("not listening on :{}", engine.default_port));
8923        }
8924
8925        // 3. Windows service check
8926        #[cfg(target_os = "windows")]
8927        {
8928            if !engine.service_names.is_empty() {
8929                let service_list = engine.service_names.join("','");
8930                let script = format!(
8931                    r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
8932                    service_list
8933                );
8934                if let Ok(o) = Command::new("powershell")
8935                    .args(["-NoProfile", "-Command", &script])
8936                    .output()
8937                {
8938                    let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8939                    if !text.is_empty() {
8940                        let parts: Vec<&str> = text.splitn(2, ':').collect();
8941                        let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
8942                        let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
8943                        status_parts.push(format!("service '{svc_name}': {svc_state}"));
8944                        detected = true;
8945                    }
8946                }
8947            }
8948        }
8949
8950        // 4. Linux/macOS systemctl / launchctl check
8951        #[cfg(not(target_os = "windows"))]
8952        {
8953            for svc in engine.service_names {
8954                if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
8955                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
8956                    if !state.is_empty() && state != "inactive" {
8957                        status_parts.push(format!("systemd '{svc}': {state}"));
8958                        detected = true;
8959                        break;
8960                    }
8961                }
8962            }
8963        }
8964
8965        if detected {
8966            found_any = true;
8967            let label = if engine.default_port > 0 {
8968                format!("{} (default port: {})", engine.name, engine.default_port)
8969            } else {
8970                format!("{} (file-based, no port)", engine.name)
8971            };
8972            out.push_str(&format!("[FOUND] {label}\n"));
8973            for part in &status_parts {
8974                out.push_str(&format!("  {part}\n"));
8975            }
8976            out.push('\n');
8977        }
8978    }
8979
8980    if !found_any {
8981        out.push_str("No local database engines detected.\n");
8982        out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
8983        out.push_str(
8984            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
8985        );
8986    } else {
8987        out.push_str("---\n");
8988        out.push_str(
8989            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
8990        );
8991        out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
8992    }
8993
8994    Ok(out.trim_end().to_string())
8995}
8996
8997// ── user_accounts ─────────────────────────────────────────────────────────────
8998
8999fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
9000    let mut out = String::from("Host inspection: user_accounts\n\n");
9001
9002    #[cfg(target_os = "windows")]
9003    {
9004        let users_out = Command::new("powershell")
9005            .args([
9006                "-NoProfile", "-NonInteractive", "-Command",
9007                "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \"  $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
9008            ])
9009            .output()
9010            .ok()
9011            .and_then(|o| String::from_utf8(o.stdout).ok())
9012            .unwrap_or_default();
9013
9014        out.push_str("=== Local User Accounts ===\n");
9015        if users_out.trim().is_empty() {
9016            out.push_str("  (requires elevation or Get-LocalUser unavailable)\n");
9017        } else {
9018            for line in users_out.lines().take(max_entries) {
9019                if !line.trim().is_empty() {
9020                    out.push_str(line);
9021                    out.push('\n');
9022                }
9023            }
9024        }
9025
9026        let admins_out = Command::new("powershell")
9027            .args([
9028                "-NoProfile", "-NonInteractive", "-Command",
9029                "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \"  $($_.ObjectClass): $($_.Name)\" }",
9030            ])
9031            .output()
9032            .ok()
9033            .and_then(|o| String::from_utf8(o.stdout).ok())
9034            .unwrap_or_default();
9035
9036        out.push_str("\n=== Administrators Group Members ===\n");
9037        if admins_out.trim().is_empty() {
9038            out.push_str("  (unable to retrieve)\n");
9039        } else {
9040            out.push_str(admins_out.trim());
9041            out.push('\n');
9042        }
9043
9044        let sessions_out = Command::new("powershell")
9045            .args([
9046                "-NoProfile",
9047                "-NonInteractive",
9048                "-Command",
9049                "query user 2>$null",
9050            ])
9051            .output()
9052            .ok()
9053            .and_then(|o| String::from_utf8(o.stdout).ok())
9054            .unwrap_or_default();
9055
9056        out.push_str("\n=== Active Logon Sessions ===\n");
9057        if sessions_out.trim().is_empty() {
9058            out.push_str("  (none or requires elevation)\n");
9059        } else {
9060            for line in sessions_out.lines().take(max_entries) {
9061                if !line.trim().is_empty() {
9062                    out.push_str(&format!("  {}\n", line));
9063                }
9064            }
9065        }
9066
9067        let is_admin = Command::new("powershell")
9068            .args([
9069                "-NoProfile", "-NonInteractive", "-Command",
9070                "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
9071            ])
9072            .output()
9073            .ok()
9074            .and_then(|o| String::from_utf8(o.stdout).ok())
9075            .map(|s| s.trim().to_lowercase())
9076            .unwrap_or_default();
9077
9078        out.push_str("\n=== Current Session Elevation ===\n");
9079        out.push_str(&format!(
9080            "  Running as Administrator: {}\n",
9081            if is_admin.contains("true") {
9082                "YES"
9083            } else {
9084                "no"
9085            }
9086        ));
9087    }
9088
9089    #[cfg(not(target_os = "windows"))]
9090    {
9091        let who_out = Command::new("who")
9092            .output()
9093            .ok()
9094            .and_then(|o| String::from_utf8(o.stdout).ok())
9095            .unwrap_or_default();
9096        out.push_str("=== Active Sessions ===\n");
9097        if who_out.trim().is_empty() {
9098            out.push_str("  (none)\n");
9099        } else {
9100            for line in who_out.lines().take(max_entries) {
9101                out.push_str(&format!("  {}\n", line));
9102            }
9103        }
9104        let id_out = Command::new("id")
9105            .output()
9106            .ok()
9107            .and_then(|o| String::from_utf8(o.stdout).ok())
9108            .unwrap_or_default();
9109        out.push_str(&format!("\n=== Current User ===\n  {}\n", id_out.trim()));
9110    }
9111
9112    Ok(out.trim_end().to_string())
9113}
9114
9115// ── audit_policy ──────────────────────────────────────────────────────────────
9116
9117fn inspect_audit_policy() -> Result<String, String> {
9118    let mut out = String::from("Host inspection: audit_policy\n\n");
9119
9120    #[cfg(target_os = "windows")]
9121    {
9122        let auditpol_out = Command::new("auditpol")
9123            .args(["/get", "/category:*"])
9124            .output()
9125            .ok()
9126            .and_then(|o| String::from_utf8(o.stdout).ok())
9127            .unwrap_or_default();
9128
9129        if auditpol_out.trim().is_empty()
9130            || auditpol_out.to_lowercase().contains("access is denied")
9131        {
9132            out.push_str("Audit policy requires Administrator elevation to read.\n");
9133            out.push_str(
9134                "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
9135            );
9136        } else {
9137            out.push_str("=== Windows Audit Policy ===\n");
9138            let mut any_enabled = false;
9139            for line in auditpol_out.lines() {
9140                let trimmed = line.trim();
9141                if trimmed.is_empty() {
9142                    continue;
9143                }
9144                if trimmed.contains("Success") || trimmed.contains("Failure") {
9145                    out.push_str(&format!("  [ENABLED] {}\n", trimmed));
9146                    any_enabled = true;
9147                } else {
9148                    out.push_str(&format!("  {}\n", trimmed));
9149                }
9150            }
9151            if !any_enabled {
9152                out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
9153                out.push_str(
9154                    "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
9155                );
9156            }
9157        }
9158
9159        let evtlog = Command::new("powershell")
9160            .args([
9161                "-NoProfile", "-NonInteractive", "-Command",
9162                "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9163            ])
9164            .output()
9165            .ok()
9166            .and_then(|o| String::from_utf8(o.stdout).ok())
9167            .map(|s| s.trim().to_string())
9168            .unwrap_or_default();
9169
9170        out.push_str(&format!(
9171            "\n=== Windows Event Log Service ===\n  Status: {}\n",
9172            if evtlog.is_empty() {
9173                "unknown".to_string()
9174            } else {
9175                evtlog
9176            }
9177        ));
9178    }
9179
9180    #[cfg(not(target_os = "windows"))]
9181    {
9182        let auditd_status = Command::new("systemctl")
9183            .args(["is-active", "auditd"])
9184            .output()
9185            .ok()
9186            .and_then(|o| String::from_utf8(o.stdout).ok())
9187            .map(|s| s.trim().to_string())
9188            .unwrap_or_else(|| "not found".to_string());
9189
9190        out.push_str(&format!(
9191            "=== auditd service ===\n  Status: {}\n",
9192            auditd_status
9193        ));
9194
9195        if auditd_status == "active" {
9196            let rules = Command::new("auditctl")
9197                .args(["-l"])
9198                .output()
9199                .ok()
9200                .and_then(|o| String::from_utf8(o.stdout).ok())
9201                .unwrap_or_default();
9202            out.push_str("\n=== Active Audit Rules ===\n");
9203            if rules.trim().is_empty() || rules.contains("No rules") {
9204                out.push_str("  No rules configured.\n");
9205            } else {
9206                for line in rules.lines() {
9207                    out.push_str(&format!("  {}\n", line));
9208                }
9209            }
9210        }
9211    }
9212
9213    Ok(out.trim_end().to_string())
9214}
9215
9216// ── shares ────────────────────────────────────────────────────────────────────
9217
9218fn inspect_shares(max_entries: usize) -> Result<String, String> {
9219    let mut out = String::from("Host inspection: shares\n\n");
9220
9221    #[cfg(target_os = "windows")]
9222    {
9223        let smb_out = Command::new("powershell")
9224            .args([
9225                "-NoProfile", "-NonInteractive", "-Command",
9226                "Get-SmbShare | ForEach-Object { \"  $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9227            ])
9228            .output()
9229            .ok()
9230            .and_then(|o| String::from_utf8(o.stdout).ok())
9231            .unwrap_or_default();
9232
9233        out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9234        let smb_lines: Vec<&str> = smb_out
9235            .lines()
9236            .filter(|l| !l.trim().is_empty())
9237            .take(max_entries)
9238            .collect();
9239        if smb_lines.is_empty() {
9240            out.push_str("  No SMB shares or unable to retrieve.\n");
9241        } else {
9242            for line in &smb_lines {
9243                let name = line.trim().split('|').next().unwrap_or("").trim();
9244                if name.ends_with('$') {
9245                    out.push_str(&format!("  {}\n", line.trim()));
9246                } else {
9247                    out.push_str(&format!("  [CUSTOM] {}\n", line.trim()));
9248                }
9249            }
9250        }
9251
9252        let smb_security = Command::new("powershell")
9253            .args([
9254                "-NoProfile", "-NonInteractive", "-Command",
9255                "Get-SmbServerConfiguration | ForEach-Object { \"  SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9256            ])
9257            .output()
9258            .ok()
9259            .and_then(|o| String::from_utf8(o.stdout).ok())
9260            .unwrap_or_default();
9261
9262        out.push_str("\n=== SMB Server Security Settings ===\n");
9263        if smb_security.trim().is_empty() {
9264            out.push_str("  (unable to retrieve)\n");
9265        } else {
9266            out.push_str(smb_security.trim());
9267            out.push('\n');
9268            if smb_security.to_lowercase().contains("smb1: true") {
9269                out.push_str("  [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9270            }
9271        }
9272
9273        let drives_out = Command::new("powershell")
9274            .args([
9275                "-NoProfile", "-NonInteractive", "-Command",
9276                "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \"  $($_.Name): -> $($_.DisplayRoot)\" }",
9277            ])
9278            .output()
9279            .ok()
9280            .and_then(|o| String::from_utf8(o.stdout).ok())
9281            .unwrap_or_default();
9282
9283        out.push_str("\n=== Mapped Network Drives ===\n");
9284        if drives_out.trim().is_empty() {
9285            out.push_str("  None.\n");
9286        } else {
9287            for line in drives_out.lines().take(max_entries) {
9288                if !line.trim().is_empty() {
9289                    out.push_str(line);
9290                    out.push('\n');
9291                }
9292            }
9293        }
9294    }
9295
9296    #[cfg(not(target_os = "windows"))]
9297    {
9298        let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9299        out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9300        if smb_conf.is_empty() {
9301            out.push_str("  Not found or Samba not installed.\n");
9302        } else {
9303            for line in smb_conf.lines().take(max_entries) {
9304                out.push_str(&format!("  {}\n", line));
9305            }
9306        }
9307        let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9308        out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9309        if nfs_exports.is_empty() {
9310            out.push_str("  Not configured.\n");
9311        } else {
9312            for line in nfs_exports.lines().take(max_entries) {
9313                out.push_str(&format!("  {}\n", line));
9314            }
9315        }
9316    }
9317
9318    Ok(out.trim_end().to_string())
9319}
9320
9321// ── dns_servers ───────────────────────────────────────────────────────────────
9322
9323fn inspect_dns_servers() -> Result<String, String> {
9324    let mut out = String::from("Host inspection: dns_servers\n\n");
9325
9326    #[cfg(target_os = "windows")]
9327    {
9328        let dns_out = Command::new("powershell")
9329            .args([
9330                "-NoProfile", "-NonInteractive", "-Command",
9331                "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \"  $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9332            ])
9333            .output()
9334            .ok()
9335            .and_then(|o| String::from_utf8(o.stdout).ok())
9336            .unwrap_or_default();
9337
9338        out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9339        if dns_out.trim().is_empty() {
9340            out.push_str("  (unable to retrieve)\n");
9341        } else {
9342            for line in dns_out.lines() {
9343                if line.trim().is_empty() {
9344                    continue;
9345                }
9346                let mut annotation = "";
9347                if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9348                    annotation = "  <- Google Public DNS";
9349                } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9350                    annotation = "  <- Cloudflare DNS";
9351                } else if line.contains("9.9.9.9") {
9352                    annotation = "  <- Quad9";
9353                } else if line.contains("208.67.222") || line.contains("208.67.220") {
9354                    annotation = "  <- OpenDNS";
9355                }
9356                out.push_str(line);
9357                out.push_str(annotation);
9358                out.push('\n');
9359            }
9360        }
9361
9362        let doh_out = Command::new("powershell")
9363            .args([
9364                "-NoProfile", "-NonInteractive", "-Command",
9365                "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \"  $($_.ServerAddress): $($_.DohTemplate)\" }",
9366            ])
9367            .output()
9368            .ok()
9369            .and_then(|o| String::from_utf8(o.stdout).ok())
9370            .unwrap_or_default();
9371
9372        out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9373        if doh_out.trim().is_empty() {
9374            out.push_str("  Not configured (plain DNS).\n");
9375        } else {
9376            out.push_str(doh_out.trim());
9377            out.push('\n');
9378        }
9379
9380        let suffixes = Command::new("powershell")
9381            .args([
9382                "-NoProfile", "-NonInteractive", "-Command",
9383                "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \"  $_\" }",
9384            ])
9385            .output()
9386            .ok()
9387            .and_then(|o| String::from_utf8(o.stdout).ok())
9388            .unwrap_or_default();
9389
9390        if !suffixes.trim().is_empty() {
9391            out.push_str("\n=== DNS Search Suffix List ===\n");
9392            out.push_str(suffixes.trim());
9393            out.push('\n');
9394        }
9395    }
9396
9397    #[cfg(not(target_os = "windows"))]
9398    {
9399        let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9400        out.push_str("=== /etc/resolv.conf ===\n");
9401        if resolv.is_empty() {
9402            out.push_str("  Not found.\n");
9403        } else {
9404            for line in resolv.lines() {
9405                if !line.trim().is_empty() && !line.starts_with('#') {
9406                    out.push_str(&format!("  {}\n", line));
9407                }
9408            }
9409        }
9410        let resolved_out = Command::new("resolvectl")
9411            .args(["status", "--no-pager"])
9412            .output()
9413            .ok()
9414            .and_then(|o| String::from_utf8(o.stdout).ok())
9415            .unwrap_or_default();
9416        if !resolved_out.is_empty() {
9417            out.push_str("\n=== systemd-resolved ===\n");
9418            for line in resolved_out.lines().take(30) {
9419                out.push_str(&format!("  {}\n", line));
9420            }
9421        }
9422    }
9423
9424    Ok(out.trim_end().to_string())
9425}
9426
9427fn inspect_bitlocker() -> Result<String, String> {
9428    let mut out = String::from("Host inspection: bitlocker\n\n");
9429
9430    #[cfg(target_os = "windows")]
9431    {
9432        let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
9433        let output = Command::new("powershell")
9434            .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
9435            .output()
9436            .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
9437
9438        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9439        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
9440
9441        if !stdout.trim().is_empty() {
9442            out.push_str("=== BitLocker Volumes ===\n");
9443            for line in stdout.lines() {
9444                out.push_str(&format!("  {}\n", line));
9445            }
9446        } else if !stderr.trim().is_empty() {
9447            if stderr.contains("Access is denied") {
9448                out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
9449            } else {
9450                out.push_str(&format!(
9451                    "Error retrieving BitLocker info: {}\n",
9452                    stderr.trim()
9453                ));
9454            }
9455        } else {
9456            out.push_str("No BitLocker volumes detected or access denied.\n");
9457        }
9458    }
9459
9460    #[cfg(not(target_os = "windows"))]
9461    {
9462        out.push_str(
9463            "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
9464        );
9465        let lsblk = Command::new("lsblk")
9466            .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
9467            .output()
9468            .ok()
9469            .and_then(|o| String::from_utf8(o.stdout).ok())
9470            .unwrap_or_default();
9471        if lsblk.contains("crypto_LUKS") {
9472            out.push_str("=== LUKS Encrypted Volumes ===\n");
9473            for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
9474                out.push_str(&format!("  {}\n", line));
9475            }
9476        } else {
9477            out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
9478        }
9479    }
9480
9481    Ok(out.trim_end().to_string())
9482}
9483
9484fn inspect_rdp() -> Result<String, String> {
9485    let mut out = String::from("Host inspection: rdp\n\n");
9486
9487    #[cfg(target_os = "windows")]
9488    {
9489        let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
9490        let f_deny = Command::new("powershell")
9491            .args([
9492                "-NoProfile",
9493                "-Command",
9494                &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
9495            ])
9496            .output()
9497            .ok()
9498            .and_then(|o| String::from_utf8(o.stdout).ok())
9499            .unwrap_or_default()
9500            .trim()
9501            .to_string();
9502
9503        let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
9504        out.push_str(&format!("=== RDP Status: {} ===\n", status));
9505
9506        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"])
9507            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
9508        out.push_str(&format!(
9509            "  Port: {}\n",
9510            if port.is_empty() {
9511                "3389 (default)"
9512            } else {
9513                &port
9514            }
9515        ));
9516
9517        let nla = Command::new("powershell")
9518            .args([
9519                "-NoProfile",
9520                "-Command",
9521                &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
9522            ])
9523            .output()
9524            .ok()
9525            .and_then(|o| String::from_utf8(o.stdout).ok())
9526            .unwrap_or_default()
9527            .trim()
9528            .to_string();
9529        out.push_str(&format!(
9530            "  NLA Required: {}\n",
9531            if nla == "1" { "Yes" } else { "No" }
9532        ));
9533
9534        out.push_str("\n=== Active Sessions ===\n");
9535        let qwinsta = Command::new("qwinsta")
9536            .output()
9537            .ok()
9538            .and_then(|o| String::from_utf8(o.stdout).ok())
9539            .unwrap_or_default();
9540        if qwinsta.trim().is_empty() {
9541            out.push_str("  No active sessions listed.\n");
9542        } else {
9543            for line in qwinsta.lines() {
9544                out.push_str(&format!("  {}\n", line));
9545            }
9546        }
9547
9548        out.push_str("\n=== Firewall Rule Check ===\n");
9549        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))\" }"])
9550            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
9551        if fw.trim().is_empty() {
9552            out.push_str("  No enabled RDP firewall rules found.\n");
9553        } else {
9554            out.push_str(fw.trim_end());
9555            out.push('\n');
9556        }
9557    }
9558
9559    #[cfg(not(target_os = "windows"))]
9560    {
9561        out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
9562        let ss = Command::new("ss")
9563            .args(["-tlnp"])
9564            .output()
9565            .ok()
9566            .and_then(|o| String::from_utf8(o.stdout).ok())
9567            .unwrap_or_default();
9568        let matches: Vec<&str> = ss
9569            .lines()
9570            .filter(|l| l.contains(":3389") || l.contains(":590"))
9571            .collect();
9572        if matches.is_empty() {
9573            out.push_str("  No RDP/VNC listeners detected via 'ss'.\n");
9574        } else {
9575            for m in matches {
9576                out.push_str(&format!("  {}\n", m));
9577            }
9578        }
9579    }
9580
9581    Ok(out.trim_end().to_string())
9582}
9583
9584fn inspect_shadow_copies() -> Result<String, String> {
9585    let mut out = String::from("Host inspection: shadow_copies\n\n");
9586
9587    #[cfg(target_os = "windows")]
9588    {
9589        let output = Command::new("vssadmin")
9590            .args(["list", "shadows"])
9591            .output()
9592            .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
9593        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9594
9595        if stdout.contains("No items found") || stdout.trim().is_empty() {
9596            out.push_str("No Volume Shadow Copies found.\n");
9597        } else {
9598            out.push_str("=== Volume Shadow Copies ===\n");
9599            for line in stdout.lines().take(50) {
9600                if line.contains("Creation Time:")
9601                    || line.contains("Contents:")
9602                    || line.contains("Volume Name:")
9603                {
9604                    out.push_str(&format!("  {}\n", line.trim()));
9605                }
9606            }
9607        }
9608
9609        out.push_str("\n=== Shadow Copy Storage ===\n");
9610        let storage_out = Command::new("vssadmin")
9611            .args(["list", "shadowstorage"])
9612            .output()
9613            .ok();
9614        if let Some(o) = storage_out {
9615            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9616            for line in stdout.lines() {
9617                if line.contains("Used Shadow Copy Storage space:")
9618                    || line.contains("Max Shadow Copy Storage space:")
9619                {
9620                    out.push_str(&format!("  {}\n", line.trim()));
9621                }
9622            }
9623        }
9624    }
9625
9626    #[cfg(not(target_os = "windows"))]
9627    {
9628        out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
9629        let lvs = Command::new("lvs")
9630            .output()
9631            .ok()
9632            .and_then(|o| String::from_utf8(o.stdout).ok())
9633            .unwrap_or_default();
9634        if !lvs.is_empty() {
9635            out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
9636            out.push_str(&lvs);
9637        } else {
9638            out.push_str("No LVM volumes detected.\n");
9639        }
9640    }
9641
9642    Ok(out.trim_end().to_string())
9643}
9644
9645fn inspect_pagefile() -> Result<String, String> {
9646    let mut out = String::from("Host inspection: pagefile\n\n");
9647
9648    #[cfg(target_os = "windows")]
9649    {
9650        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)\" }";
9651        let output = Command::new("powershell")
9652            .args(["-NoProfile", "-Command", ps_cmd])
9653            .output()
9654            .ok()
9655            .and_then(|o| String::from_utf8(o.stdout).ok())
9656            .unwrap_or_default();
9657
9658        if output.trim().is_empty() {
9659            out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
9660            let managed = Command::new("powershell")
9661                .args([
9662                    "-NoProfile",
9663                    "-Command",
9664                    "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
9665                ])
9666                .output()
9667                .ok()
9668                .and_then(|o| String::from_utf8(o.stdout).ok())
9669                .unwrap_or_default()
9670                .trim()
9671                .to_string();
9672            out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
9673        } else {
9674            out.push_str("=== Page File Usage ===\n");
9675            out.push_str(&output);
9676        }
9677    }
9678
9679    #[cfg(not(target_os = "windows"))]
9680    {
9681        out.push_str("=== Swap Usage (Linux/macOS) ===\n");
9682        let swap = Command::new("swapon")
9683            .args(["--show"])
9684            .output()
9685            .ok()
9686            .and_then(|o| String::from_utf8(o.stdout).ok())
9687            .unwrap_or_default();
9688        if swap.is_empty() {
9689            let free = Command::new("free")
9690                .args(["-h"])
9691                .output()
9692                .ok()
9693                .and_then(|o| String::from_utf8(o.stdout).ok())
9694                .unwrap_or_default();
9695            out.push_str(&free);
9696        } else {
9697            out.push_str(&swap);
9698        }
9699    }
9700
9701    Ok(out.trim_end().to_string())
9702}
9703
9704fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
9705    let mut out = String::from("Host inspection: windows_features\n\n");
9706
9707    #[cfg(target_os = "windows")]
9708    {
9709        out.push_str("=== Quick Check: Notable Features ===\n");
9710        let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
9711        let output = Command::new("powershell")
9712            .args(["-NoProfile", "-Command", quick_ps])
9713            .output()
9714            .ok();
9715
9716        if let Some(o) = output {
9717            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9718            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
9719
9720            if !stdout.trim().is_empty() {
9721                for f in stdout.lines() {
9722                    out.push_str(&format!("  [ENABLED] {}\n", f));
9723                }
9724            } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
9725                out.push_str("  Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
9726            } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
9727                out.push_str(
9728                    "  No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
9729                );
9730            }
9731        }
9732
9733        out.push_str(&format!(
9734            "\n=== All Enabled Features (capped at {}) ===\n",
9735            max_entries
9736        ));
9737        let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
9738        let all_out = Command::new("powershell")
9739            .args(["-NoProfile", "-Command", &all_ps])
9740            .output()
9741            .ok();
9742        if let Some(o) = all_out {
9743            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9744            if !stdout.trim().is_empty() {
9745                out.push_str(&stdout);
9746            }
9747        }
9748    }
9749
9750    #[cfg(not(target_os = "windows"))]
9751    {
9752        let _ = max_entries;
9753        out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
9754    }
9755
9756    Ok(out.trim_end().to_string())
9757}
9758
9759fn inspect_audio(max_entries: usize) -> Result<String, String> {
9760    let mut out = String::from("Host inspection: audio\n\n");
9761
9762    #[cfg(target_os = "windows")]
9763    {
9764        let n = max_entries.clamp(5, 20);
9765        let services = collect_services().unwrap_or_default();
9766        let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
9767        let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
9768
9769        let core_services: Vec<&ServiceEntry> = services
9770            .iter()
9771            .filter(|entry| {
9772                core_service_names
9773                    .iter()
9774                    .any(|name| entry.name.eq_ignore_ascii_case(name))
9775            })
9776            .collect();
9777        let bluetooth_audio_services: Vec<&ServiceEntry> = services
9778            .iter()
9779            .filter(|entry| {
9780                bluetooth_audio_service_names
9781                    .iter()
9782                    .any(|name| entry.name.eq_ignore_ascii_case(name))
9783            })
9784            .collect();
9785
9786        let probe_script = r#"
9787$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
9788    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9789$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
9790    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9791$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
9792    Select-Object Name, Status, Manufacturer, PNPDeviceID)
9793[pscustomobject]@{
9794    Media = $media
9795    Endpoints = $endpoints
9796    SoundDevices = $sound
9797} | ConvertTo-Json -Compress -Depth 4
9798"#;
9799        let probe_raw = Command::new("powershell")
9800            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
9801            .output()
9802            .ok()
9803            .and_then(|o| String::from_utf8(o.stdout).ok())
9804            .unwrap_or_default();
9805        let probe_loaded = !probe_raw.trim().is_empty();
9806        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
9807
9808        let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
9809        let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
9810        let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
9811
9812        let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
9813            .iter()
9814            .filter(|device| !is_microphone_like_name(&device.name))
9815            .collect();
9816        let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
9817            .iter()
9818            .filter(|device| is_microphone_like_name(&device.name))
9819            .collect();
9820        let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
9821            .iter()
9822            .filter(|device| is_bluetooth_like_name(&device.name))
9823            .collect();
9824        let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
9825            .iter()
9826            .filter(|device| windows_device_has_issue(device))
9827            .collect();
9828        let media_problems: Vec<&WindowsPnpDevice> = media_devices
9829            .iter()
9830            .filter(|device| windows_device_has_issue(device))
9831            .collect();
9832        let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
9833            .iter()
9834            .filter(|device| windows_sound_device_has_issue(device))
9835            .collect();
9836
9837        let mut findings = Vec::new();
9838
9839        let stopped_core_services: Vec<&ServiceEntry> = core_services
9840            .iter()
9841            .copied()
9842            .filter(|service| !service_is_running(service))
9843            .collect();
9844        if !stopped_core_services.is_empty() {
9845            let names = stopped_core_services
9846                .iter()
9847                .map(|service| service.name.as_str())
9848                .collect::<Vec<_>>()
9849                .join(", ");
9850            findings.push(AuditFinding {
9851                finding: format!("Core audio services are not running: {names}"),
9852                impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
9853                fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
9854            });
9855        }
9856
9857        if probe_loaded
9858            && endpoints.is_empty()
9859            && media_devices.is_empty()
9860            && sound_devices.is_empty()
9861        {
9862            findings.push(AuditFinding {
9863                finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
9864                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(),
9865                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(),
9866            });
9867        }
9868
9869        if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
9870        {
9871            let mut problem_labels = Vec::new();
9872            problem_labels.extend(
9873                endpoint_problems
9874                    .iter()
9875                    .take(3)
9876                    .map(|device| device.name.clone()),
9877            );
9878            problem_labels.extend(
9879                media_problems
9880                    .iter()
9881                    .take(3)
9882                    .map(|device| device.name.clone()),
9883            );
9884            problem_labels.extend(
9885                sound_problems
9886                    .iter()
9887                    .take(3)
9888                    .map(|device| device.name.clone()),
9889            );
9890            findings.push(AuditFinding {
9891                finding: format!(
9892                    "Windows reports audio device issues for: {}",
9893                    problem_labels.join(", ")
9894                ),
9895                impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
9896                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(),
9897            });
9898        }
9899
9900        let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
9901            .iter()
9902            .copied()
9903            .filter(|service| !service_is_running(service))
9904            .collect();
9905        if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
9906            let names = stopped_bt_audio_services
9907                .iter()
9908                .map(|service| service.name.as_str())
9909                .collect::<Vec<_>>()
9910                .join(", ");
9911            findings.push(AuditFinding {
9912                finding: format!(
9913                    "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
9914                ),
9915                impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
9916                fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
9917            });
9918        }
9919
9920        out.push_str("=== Findings ===\n");
9921        if findings.is_empty() {
9922            out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
9923            out.push_str("  Impact: Playback and recording look structurally present from this inspection pass.\n");
9924            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");
9925        } else {
9926            for finding in &findings {
9927                out.push_str(&format!("- Finding: {}\n", finding.finding));
9928                out.push_str(&format!("  Impact: {}\n", finding.impact));
9929                out.push_str(&format!("  Fix: {}\n", finding.fix));
9930            }
9931        }
9932
9933        out.push_str("\n=== Audio services ===\n");
9934        if core_services.is_empty() && bluetooth_audio_services.is_empty() {
9935            out.push_str(
9936                "- No Windows audio services were retrieved from the service inventory.\n",
9937            );
9938        } else {
9939            for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
9940                out.push_str(&format!(
9941                    "- {} | Status: {} | Startup: {}\n",
9942                    service.name,
9943                    service.status,
9944                    service.startup.as_deref().unwrap_or("Unknown")
9945                ));
9946            }
9947        }
9948
9949        out.push_str("\n=== Playback and recording endpoints ===\n");
9950        if !probe_loaded {
9951            out.push_str("- Windows endpoint inventory probe returned no data.\n");
9952        } else if endpoints.is_empty() {
9953            out.push_str("- No audio endpoints detected.\n");
9954        } else {
9955            out.push_str(&format!(
9956                "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
9957                playback_endpoints.len(),
9958                recording_endpoints.len()
9959            ));
9960            for device in playback_endpoints.iter().take(n) {
9961                out.push_str(&format!(
9962                    "- [PLAYBACK] {} | Status: {}{}\n",
9963                    device.name,
9964                    device.status,
9965                    device
9966                        .problem
9967                        .filter(|problem| *problem != 0)
9968                        .map(|problem| format!(" | ProblemCode: {problem}"))
9969                        .unwrap_or_default()
9970                ));
9971            }
9972            for device in recording_endpoints.iter().take(n) {
9973                out.push_str(&format!(
9974                    "- [MIC] {} | Status: {}{}\n",
9975                    device.name,
9976                    device.status,
9977                    device
9978                        .problem
9979                        .filter(|problem| *problem != 0)
9980                        .map(|problem| format!(" | ProblemCode: {problem}"))
9981                        .unwrap_or_default()
9982                ));
9983            }
9984        }
9985
9986        out.push_str("\n=== Sound hardware devices ===\n");
9987        if sound_devices.is_empty() {
9988            out.push_str("- No Win32_SoundDevice entries were returned.\n");
9989        } else {
9990            for device in sound_devices.iter().take(n) {
9991                out.push_str(&format!(
9992                    "- {} | Status: {}{}\n",
9993                    device.name,
9994                    device.status,
9995                    device
9996                        .manufacturer
9997                        .as_deref()
9998                        .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
9999                        .unwrap_or_default()
10000                ));
10001            }
10002        }
10003
10004        out.push_str("\n=== Media-class device inventory ===\n");
10005        if media_devices.is_empty() {
10006            out.push_str("- No media-class PnP devices were returned.\n");
10007        } else {
10008            for device in media_devices.iter().take(n) {
10009                out.push_str(&format!(
10010                    "- {} | Status: {}{}\n",
10011                    device.name,
10012                    device.status,
10013                    device
10014                        .class_name
10015                        .as_deref()
10016                        .map(|class_name| format!(" | Class: {class_name}"))
10017                        .unwrap_or_default()
10018                ));
10019            }
10020        }
10021    }
10022
10023    #[cfg(not(target_os = "windows"))]
10024    {
10025        let _ = max_entries;
10026        out.push_str(
10027            "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
10028        );
10029        out.push_str(
10030            "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
10031        );
10032    }
10033
10034    Ok(out.trim_end().to_string())
10035}
10036
10037fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
10038    let mut out = String::from("Host inspection: bluetooth\n\n");
10039
10040    #[cfg(target_os = "windows")]
10041    {
10042        let n = max_entries.clamp(5, 20);
10043        let services = collect_services().unwrap_or_default();
10044        let bluetooth_services: Vec<&ServiceEntry> = services
10045            .iter()
10046            .filter(|entry| {
10047                entry.name.eq_ignore_ascii_case("bthserv")
10048                    || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
10049                    || entry.name.eq_ignore_ascii_case("BTAGService")
10050                    || entry.name.starts_with("BluetoothUserService")
10051                    || entry
10052                        .display_name
10053                        .as_deref()
10054                        .unwrap_or("")
10055                        .to_ascii_lowercase()
10056                        .contains("bluetooth")
10057            })
10058            .collect();
10059
10060        let probe_script = r#"
10061$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
10062    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10063$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
10064    Where-Object {
10065        $_.Class -eq 'Bluetooth' -or
10066        $_.FriendlyName -match 'Bluetooth' -or
10067        $_.InstanceId -like 'BTH*'
10068    } |
10069    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10070$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
10071    Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
10072    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
10073[pscustomobject]@{
10074    Radios = $radios
10075    Devices = $devices
10076    AudioEndpoints = $audio
10077} | ConvertTo-Json -Compress -Depth 4
10078"#;
10079        let probe_raw = Command::new("powershell")
10080            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
10081            .output()
10082            .ok()
10083            .and_then(|o| String::from_utf8(o.stdout).ok())
10084            .unwrap_or_default();
10085        let probe_loaded = !probe_raw.trim().is_empty();
10086        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
10087
10088        let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
10089        let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
10090        let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
10091        let radio_problems: Vec<&WindowsPnpDevice> = radios
10092            .iter()
10093            .filter(|device| windows_device_has_issue(device))
10094            .collect();
10095        let device_problems: Vec<&WindowsPnpDevice> = devices
10096            .iter()
10097            .filter(|device| windows_device_has_issue(device))
10098            .collect();
10099
10100        let mut findings = Vec::new();
10101
10102        if probe_loaded && radios.is_empty() {
10103            findings.push(AuditFinding {
10104                finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
10105                impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
10106                fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
10107            });
10108        }
10109
10110        let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
10111            .iter()
10112            .copied()
10113            .filter(|service| !service_is_running(service))
10114            .collect();
10115        if !stopped_bluetooth_services.is_empty() {
10116            let names = stopped_bluetooth_services
10117                .iter()
10118                .map(|service| service.name.as_str())
10119                .collect::<Vec<_>>()
10120                .join(", ");
10121            findings.push(AuditFinding {
10122                finding: format!("Bluetooth-related services are not fully running: {names}"),
10123                impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
10124                fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
10125            });
10126        }
10127
10128        if !radio_problems.is_empty() || !device_problems.is_empty() {
10129            let problem_labels = radio_problems
10130                .iter()
10131                .chain(device_problems.iter())
10132                .take(5)
10133                .map(|device| device.name.as_str())
10134                .collect::<Vec<_>>()
10135                .join(", ");
10136            findings.push(AuditFinding {
10137                finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
10138                impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
10139                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(),
10140            });
10141        }
10142
10143        if !audio_endpoints.is_empty()
10144            && bluetooth_services
10145                .iter()
10146                .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10147            && bluetooth_services
10148                .iter()
10149                .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
10150                .any(|service| !service_is_running(service))
10151        {
10152            findings.push(AuditFinding {
10153                finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
10154                impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
10155                fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
10156            });
10157        }
10158
10159        out.push_str("=== Findings ===\n");
10160        if findings.is_empty() {
10161            out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10162            out.push_str("  Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10163            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");
10164        } else {
10165            for finding in &findings {
10166                out.push_str(&format!("- Finding: {}\n", finding.finding));
10167                out.push_str(&format!("  Impact: {}\n", finding.impact));
10168                out.push_str(&format!("  Fix: {}\n", finding.fix));
10169            }
10170        }
10171
10172        out.push_str("\n=== Bluetooth services ===\n");
10173        if bluetooth_services.is_empty() {
10174            out.push_str(
10175                "- No Bluetooth-related services were retrieved from the service inventory.\n",
10176            );
10177        } else {
10178            for service in bluetooth_services.iter().take(n) {
10179                out.push_str(&format!(
10180                    "- {} | Status: {} | Startup: {}\n",
10181                    service.name,
10182                    service.status,
10183                    service.startup.as_deref().unwrap_or("Unknown")
10184                ));
10185            }
10186        }
10187
10188        out.push_str("\n=== Bluetooth radios and adapters ===\n");
10189        if !probe_loaded {
10190            out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10191        } else if radios.is_empty() {
10192            out.push_str("- No Bluetooth radios detected.\n");
10193        } else {
10194            for device in radios.iter().take(n) {
10195                out.push_str(&format!(
10196                    "- {} | Status: {}{}\n",
10197                    device.name,
10198                    device.status,
10199                    device
10200                        .problem
10201                        .filter(|problem| *problem != 0)
10202                        .map(|problem| format!(" | ProblemCode: {problem}"))
10203                        .unwrap_or_default()
10204                ));
10205            }
10206        }
10207
10208        out.push_str("\n=== Bluetooth-associated devices ===\n");
10209        if devices.is_empty() {
10210            out.push_str("- No Bluetooth-associated device nodes detected.\n");
10211        } else {
10212            for device in devices.iter().take(n) {
10213                out.push_str(&format!(
10214                    "- {} | Status: {}{}\n",
10215                    device.name,
10216                    device.status,
10217                    device
10218                        .class_name
10219                        .as_deref()
10220                        .map(|class_name| format!(" | Class: {class_name}"))
10221                        .unwrap_or_default()
10222                ));
10223            }
10224        }
10225
10226        out.push_str("\n=== Bluetooth audio endpoints ===\n");
10227        if audio_endpoints.is_empty() {
10228            out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10229        } else {
10230            for device in audio_endpoints.iter().take(n) {
10231                out.push_str(&format!(
10232                    "- {} | Status: {}{}\n",
10233                    device.name,
10234                    device.status,
10235                    device
10236                        .instance_id
10237                        .as_deref()
10238                        .map(|instance_id| format!(" | Instance: {instance_id}"))
10239                        .unwrap_or_default()
10240                ));
10241            }
10242        }
10243    }
10244
10245    #[cfg(not(target_os = "windows"))]
10246    {
10247        let _ = max_entries;
10248        out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10249        out.push_str(
10250            "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10251        );
10252    }
10253
10254    Ok(out.trim_end().to_string())
10255}
10256
10257fn inspect_printers(max_entries: usize) -> Result<String, String> {
10258    let mut out = String::from("Host inspection: printers\n\n");
10259
10260    #[cfg(target_os = "windows")]
10261    {
10262        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)])
10263            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10264        if list.trim().is_empty() {
10265            out.push_str("No printers detected.\n");
10266        } else {
10267            out.push_str("=== Installed Printers ===\n");
10268            out.push_str(&list);
10269        }
10270
10271        let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \"  [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10272            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10273        if !jobs.trim().is_empty() {
10274            out.push_str("\n=== Active Print Jobs ===\n");
10275            out.push_str(&jobs);
10276        }
10277    }
10278
10279    #[cfg(not(target_os = "windows"))]
10280    {
10281        let _ = max_entries;
10282        out.push_str("Checking LPSTAT for printers...\n");
10283        let lpstat = Command::new("lpstat")
10284            .args(["-p", "-d"])
10285            .output()
10286            .ok()
10287            .and_then(|o| String::from_utf8(o.stdout).ok())
10288            .unwrap_or_default();
10289        if lpstat.is_empty() {
10290            out.push_str("  No CUPS/LP printers found.\n");
10291        } else {
10292            out.push_str(&lpstat);
10293        }
10294    }
10295
10296    Ok(out.trim_end().to_string())
10297}
10298
10299fn inspect_winrm() -> Result<String, String> {
10300    let mut out = String::from("Host inspection: winrm\n\n");
10301
10302    #[cfg(target_os = "windows")]
10303    {
10304        let svc = Command::new("powershell")
10305            .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10306            .output()
10307            .ok()
10308            .and_then(|o| String::from_utf8(o.stdout).ok())
10309            .unwrap_or_default()
10310            .trim()
10311            .to_string();
10312        out.push_str(&format!(
10313            "WinRM Service Status: {}\n\n",
10314            if svc.is_empty() { "NOT_FOUND" } else { &svc }
10315        ));
10316
10317        out.push_str("=== WinRM Listeners ===\n");
10318        let output = Command::new("powershell")
10319            .args([
10320                "-NoProfile",
10321                "-Command",
10322                "winrm enumerate winrm/config/listener 2>$null",
10323            ])
10324            .output()
10325            .ok();
10326        if let Some(o) = output {
10327            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10328            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10329
10330            if !stdout.trim().is_empty() {
10331                for line in stdout.lines() {
10332                    if line.contains("Address =")
10333                        || line.contains("Transport =")
10334                        || line.contains("Port =")
10335                    {
10336                        out.push_str(&format!("  {}\n", line.trim()));
10337                    }
10338                }
10339            } else if stderr.contains("Access is denied") {
10340                out.push_str("  Error: Access denied to WinRM configuration.\n");
10341            } else {
10342                out.push_str("  No listeners configured.\n");
10343            }
10344        }
10345
10346        out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
10347        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))\" }"])
10348            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10349        if test_out.trim().is_empty() {
10350            out.push_str("  WinRM not responding to local WS-Man requests.\n");
10351        } else {
10352            out.push_str(&test_out);
10353        }
10354    }
10355
10356    #[cfg(not(target_os = "windows"))]
10357    {
10358        out.push_str(
10359            "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
10360        );
10361        let ss = Command::new("ss")
10362            .args(["-tln"])
10363            .output()
10364            .ok()
10365            .and_then(|o| String::from_utf8(o.stdout).ok())
10366            .unwrap_or_default();
10367        if ss.contains(":5985") || ss.contains(":5986") {
10368            out.push_str("  WinRM ports (5985/5986) are listening.\n");
10369        } else {
10370            out.push_str("  WinRM ports not detected.\n");
10371        }
10372    }
10373
10374    Ok(out.trim_end().to_string())
10375}
10376
10377fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
10378    let mut out = String::from("Host inspection: network_stats\n\n");
10379
10380    #[cfg(target_os = "windows")]
10381    {
10382        let ps_cmd = format!(
10383            "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
10384             Start-Sleep -Milliseconds 250; \
10385             $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
10386             $s2 | ForEach-Object {{ \
10387                $name = $_.Name; \
10388                $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
10389                if ($prev) {{ \
10390                    $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
10391                    $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
10392                    $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
10393                    $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
10394                    $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
10395                    $tt = [math]::Round($_.SentBytes / 1MB, 2); \
10396                    \"  $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
10397                }} \
10398             }}",
10399            max_entries
10400        );
10401        let output = Command::new("powershell")
10402            .args(["-NoProfile", "-Command", &ps_cmd])
10403            .output()
10404            .ok()
10405            .and_then(|o| String::from_utf8(o.stdout).ok())
10406            .unwrap_or_default();
10407        if output.trim().is_empty() {
10408            out.push_str("No network adapter statistics available.\n");
10409        } else {
10410            out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
10411            out.push_str(&output);
10412        }
10413
10414        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)\" } }"])
10415            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10416        if !discards.trim().is_empty() {
10417            out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
10418            out.push_str(&discards);
10419        }
10420    }
10421
10422    #[cfg(not(target_os = "windows"))]
10423    {
10424        let _ = max_entries;
10425        out.push_str("=== Network Stats (ip -s link) ===\n");
10426        let ip_s = Command::new("ip")
10427            .args(["-s", "link"])
10428            .output()
10429            .ok()
10430            .and_then(|o| String::from_utf8(o.stdout).ok())
10431            .unwrap_or_default();
10432        if ip_s.is_empty() {
10433            let netstat = Command::new("netstat")
10434                .args(["-i"])
10435                .output()
10436                .ok()
10437                .and_then(|o| String::from_utf8(o.stdout).ok())
10438                .unwrap_or_default();
10439            out.push_str(&netstat);
10440        } else {
10441            out.push_str(&ip_s);
10442        }
10443    }
10444
10445    Ok(out.trim_end().to_string())
10446}
10447
10448fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
10449    let mut out = String::from("Host inspection: udp_ports\n\n");
10450
10451    #[cfg(target_os = "windows")]
10452    {
10453        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);
10454        let output = Command::new("powershell")
10455            .args(["-NoProfile", "-Command", &ps_cmd])
10456            .output()
10457            .ok();
10458
10459        if let Some(o) = output {
10460            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10461            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10462
10463            if !stdout.trim().is_empty() {
10464                out.push_str("=== UDP Listeners (Local:Port) ===\n");
10465                for line in stdout.lines() {
10466                    let mut note = "";
10467                    if line.contains(":53 ") {
10468                        note = " [DNS]";
10469                    } else if line.contains(":67 ") || line.contains(":68 ") {
10470                        note = " [DHCP]";
10471                    } else if line.contains(":123 ") {
10472                        note = " [NTP]";
10473                    } else if line.contains(":161 ") {
10474                        note = " [SNMP]";
10475                    } else if line.contains(":1900 ") {
10476                        note = " [SSDP/UPnP]";
10477                    } else if line.contains(":5353 ") {
10478                        note = " [mDNS]";
10479                    }
10480
10481                    out.push_str(&format!("{}{}\n", line, note));
10482                }
10483            } else if stderr.contains("Access is denied") {
10484                out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
10485            } else {
10486                out.push_str("No UDP listeners detected.\n");
10487            }
10488        }
10489    }
10490
10491    #[cfg(not(target_os = "windows"))]
10492    {
10493        let ss_out = Command::new("ss")
10494            .args(["-ulnp"])
10495            .output()
10496            .ok()
10497            .and_then(|o| String::from_utf8(o.stdout).ok())
10498            .unwrap_or_default();
10499        out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
10500        if ss_out.is_empty() {
10501            let netstat_out = Command::new("netstat")
10502                .args(["-ulnp"])
10503                .output()
10504                .ok()
10505                .and_then(|o| String::from_utf8(o.stdout).ok())
10506                .unwrap_or_default();
10507            if netstat_out.is_empty() {
10508                out.push_str("  Neither 'ss' nor 'netstat' available.\n");
10509            } else {
10510                for line in netstat_out.lines().take(max_entries) {
10511                    out.push_str(&format!("  {}\n", line));
10512                }
10513            }
10514        } else {
10515            for line in ss_out.lines().take(max_entries) {
10516                out.push_str(&format!("  {}\n", line));
10517            }
10518        }
10519    }
10520
10521    Ok(out.trim_end().to_string())
10522}
10523
10524fn inspect_gpo() -> Result<String, String> {
10525    let mut out = String::from("Host inspection: gpo\n\n");
10526
10527    #[cfg(target_os = "windows")]
10528    {
10529        let output = Command::new("gpresult")
10530            .args(["/r", "/scope", "computer"])
10531            .output()
10532            .ok();
10533
10534        if let Some(o) = output {
10535            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10536            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10537
10538            if stdout.contains("Applied Group Policy Objects") {
10539                out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
10540                let mut capture = false;
10541                for line in stdout.lines() {
10542                    if line.contains("Applied Group Policy Objects") {
10543                        capture = true;
10544                    } else if capture && line.contains("The following GPOs were not applied") {
10545                        break;
10546                    }
10547                    if capture && !line.trim().is_empty() {
10548                        out.push_str(&format!("  {}\n", line.trim()));
10549                    }
10550                }
10551            } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
10552                out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
10553            } else {
10554                out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
10555            }
10556        }
10557    }
10558
10559    #[cfg(not(target_os = "windows"))]
10560    {
10561        out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
10562    }
10563
10564    Ok(out.trim_end().to_string())
10565}
10566
10567fn inspect_certificates(max_entries: usize) -> Result<String, String> {
10568    let mut out = String::from("Host inspection: certificates\n\n");
10569
10570    #[cfg(target_os = "windows")]
10571    {
10572        let ps_cmd = format!(
10573            "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
10574                $days = ($_.NotAfter - (Get-Date)).Days; \
10575                $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
10576                \"  $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
10577            }}", 
10578            max_entries
10579        );
10580        let output = Command::new("powershell")
10581            .args(["-NoProfile", "-Command", &ps_cmd])
10582            .output()
10583            .ok();
10584
10585        if let Some(o) = output {
10586            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10587            if !stdout.trim().is_empty() {
10588                out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
10589                out.push_str(&stdout);
10590            } else {
10591                out.push_str("No certificates found in the Local Machine Personal store.\n");
10592            }
10593        }
10594    }
10595
10596    #[cfg(not(target_os = "windows"))]
10597    {
10598        let _ = max_entries;
10599        out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
10600        // Check standard cert locations
10601        for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
10602            if Path::new(path).exists() {
10603                out.push_str(&format!("  Cert directory found: {}\n", path));
10604            }
10605        }
10606    }
10607
10608    Ok(out.trim_end().to_string())
10609}
10610
10611fn inspect_integrity() -> Result<String, String> {
10612    let mut out = String::from("Host inspection: integrity\n\n");
10613
10614    #[cfg(target_os = "windows")]
10615    {
10616        let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
10617        let output = Command::new("powershell")
10618            .args(["-NoProfile", "-Command", &ps_cmd])
10619            .output()
10620            .ok();
10621
10622        if let Some(o) = output {
10623            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10624            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10625                out.push_str("=== Windows Component Store Health (CBS) ===\n");
10626                let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
10627                let repair = val
10628                    .get("AutoRepairNeeded")
10629                    .and_then(|v| v.as_u64())
10630                    .unwrap_or(0);
10631
10632                out.push_str(&format!(
10633                    "  Corruption Detected: {}\n",
10634                    if corrupt != 0 {
10635                        "YES (SFC/DISM recommended)"
10636                    } else {
10637                        "No"
10638                    }
10639                ));
10640                out.push_str(&format!(
10641                    "  Auto-Repair Needed: {}\n",
10642                    if repair != 0 { "YES" } else { "No" }
10643                ));
10644
10645                if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
10646                    out.push_str(&format!("  Last Repair Attempt: (Raw code: {})\n", last));
10647                }
10648            } else {
10649                out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
10650            }
10651        }
10652
10653        if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
10654            out.push_str(
10655                "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
10656            );
10657        }
10658    }
10659
10660    #[cfg(not(target_os = "windows"))]
10661    {
10662        out.push_str("System integrity check (Linux)\n\n");
10663        let pkg_check = Command::new("rpm")
10664            .args(["-Va"])
10665            .output()
10666            .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
10667            .ok();
10668        if let Some(o) = pkg_check {
10669            out.push_str("  Package verification system active.\n");
10670            if o.status.success() {
10671                out.push_str("  No major package integrity issues detected.\n");
10672            }
10673        }
10674    }
10675
10676    Ok(out.trim_end().to_string())
10677}
10678
10679fn inspect_domain() -> Result<String, String> {
10680    let mut out = String::from("Host inspection: domain\n\n");
10681
10682    #[cfg(target_os = "windows")]
10683    {
10684        out.push_str("=== Windows Domain / Workgroup Identity ===\n");
10685        let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
10686        let output = Command::new("powershell")
10687            .args(["-NoProfile", "-Command", &ps_cmd])
10688            .output()
10689            .ok();
10690
10691        if let Some(o) = output {
10692            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10693            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10694                let part_of_domain = val
10695                    .get("PartOfDomain")
10696                    .and_then(|v| v.as_bool())
10697                    .unwrap_or(false);
10698                let domain = val
10699                    .get("Domain")
10700                    .and_then(|v| v.as_str())
10701                    .unwrap_or("Unknown");
10702                let workgroup = val
10703                    .get("Workgroup")
10704                    .and_then(|v| v.as_str())
10705                    .unwrap_or("Unknown");
10706
10707                out.push_str(&format!(
10708                    "  Join Status: {}\n",
10709                    if part_of_domain {
10710                        "DOMAIN JOINED"
10711                    } else {
10712                        "WORKGROUP"
10713                    }
10714                ));
10715                if part_of_domain {
10716                    out.push_str(&format!("  Active Directory Domain: {}\n", domain));
10717                } else {
10718                    out.push_str(&format!("  Workgroup Name: {}\n", workgroup));
10719                }
10720
10721                if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
10722                    out.push_str(&format!("  NetBIOS Name: {}\n", name));
10723                }
10724            } else {
10725                out.push_str("  Domain identity data unavailable from WMI.\n");
10726            }
10727        } else {
10728            out.push_str("  Domain identity data unavailable from WMI.\n");
10729        }
10730    }
10731
10732    #[cfg(not(target_os = "windows"))]
10733    {
10734        let domainname = Command::new("domainname")
10735            .output()
10736            .ok()
10737            .and_then(|o| String::from_utf8(o.stdout).ok())
10738            .unwrap_or_default();
10739        out.push_str("=== Linux Domain Identity ===\n");
10740        if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
10741            out.push_str(&format!("  NIS/YP Domain: {}\n", domainname.trim()));
10742        } else {
10743            out.push_str("  No NIS domain configured.\n");
10744        }
10745    }
10746
10747    Ok(out.trim_end().to_string())
10748}
10749
10750fn inspect_device_health() -> Result<String, String> {
10751    let mut out = String::from("Host inspection: device_health\n\n");
10752
10753    #[cfg(target_os = "windows")]
10754    {
10755        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)\" }";
10756        let output = Command::new("powershell")
10757            .args(["-NoProfile", "-Command", ps_cmd])
10758            .output()
10759            .ok()
10760            .and_then(|o| String::from_utf8(o.stdout).ok())
10761            .unwrap_or_default();
10762
10763        if output.trim().is_empty() {
10764            out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
10765        } else {
10766            out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
10767            out.push_str(&output);
10768            out.push_str(
10769                "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
10770            );
10771        }
10772    }
10773
10774    #[cfg(not(target_os = "windows"))]
10775    {
10776        out.push_str("Checking dmesg for hardware errors...\n");
10777        let dmesg = Command::new("dmesg")
10778            .args(["--level=err,crit,alert"])
10779            .output()
10780            .ok()
10781            .and_then(|o| String::from_utf8(o.stdout).ok())
10782            .unwrap_or_default();
10783        if dmesg.is_empty() {
10784            out.push_str("  No critical hardware errors found in dmesg.\n");
10785        } else {
10786            out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
10787        }
10788    }
10789
10790    Ok(out.trim_end().to_string())
10791}
10792
10793fn inspect_drivers(max_entries: usize) -> Result<String, String> {
10794    let mut out = String::from("Host inspection: drivers\n\n");
10795
10796    #[cfg(target_os = "windows")]
10797    {
10798        out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
10799        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);
10800        let output = Command::new("powershell")
10801            .args(["-NoProfile", "-Command", &ps_cmd])
10802            .output()
10803            .ok()
10804            .and_then(|o| String::from_utf8(o.stdout).ok())
10805            .unwrap_or_default();
10806
10807        if output.trim().is_empty() {
10808            out.push_str("  No drivers retrieved via WMI.\n");
10809        } else {
10810            out.push_str(&output);
10811        }
10812    }
10813
10814    #[cfg(not(target_os = "windows"))]
10815    {
10816        out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
10817        let lsmod = Command::new("lsmod")
10818            .output()
10819            .ok()
10820            .and_then(|o| String::from_utf8(o.stdout).ok())
10821            .unwrap_or_default();
10822        out.push_str(
10823            &lsmod
10824                .lines()
10825                .take(max_entries)
10826                .collect::<Vec<_>>()
10827                .join("\n"),
10828        );
10829    }
10830
10831    Ok(out.trim_end().to_string())
10832}
10833
10834fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
10835    let mut out = String::from("Host inspection: peripherals\n\n");
10836
10837    #[cfg(target_os = "windows")]
10838    {
10839        let _ = max_entries;
10840        out.push_str("=== USB Controllers & Hubs ===\n");
10841        let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \"  $($_.Name) ($($_.Status))\" }"])
10842            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10843        out.push_str(if usb.is_empty() {
10844            "  None detected.\n"
10845        } else {
10846            &usb
10847        });
10848
10849        out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
10850        let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \"  [KB] $($_.Name) ($($_.Status))\" }"])
10851            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10852        let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \"  [PTR] $($_.Name) ($($_.Status))\" }"])
10853            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10854        out.push_str(&kb);
10855        out.push_str(&mouse);
10856
10857        out.push_str("\n=== Connected Monitors (WMI) ===\n");
10858        let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \"  Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
10859            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10860        out.push_str(if mon.is_empty() {
10861            "  No active monitors identified via WMI.\n"
10862        } else {
10863            &mon
10864        });
10865    }
10866
10867    #[cfg(not(target_os = "windows"))]
10868    {
10869        out.push_str("=== Connected USB Devices (lsusb) ===\n");
10870        let lsusb = Command::new("lsusb")
10871            .output()
10872            .ok()
10873            .and_then(|o| String::from_utf8(o.stdout).ok())
10874            .unwrap_or_default();
10875        out.push_str(
10876            &lsusb
10877                .lines()
10878                .take(max_entries)
10879                .collect::<Vec<_>>()
10880                .join("\n"),
10881        );
10882    }
10883
10884    Ok(out.trim_end().to_string())
10885}
10886
10887fn inspect_sessions(max_entries: usize) -> Result<String, String> {
10888    let mut out = String::from("Host inspection: sessions\n\n");
10889
10890    #[cfg(target_os = "windows")]
10891    {
10892        out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
10893        let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
10894    "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
10895}"#;
10896        if let Ok(o) = Command::new("powershell")
10897            .args(["-NoProfile", "-Command", script])
10898            .output()
10899        {
10900            let text = String::from_utf8_lossy(&o.stdout);
10901            let lines: Vec<&str> = text.lines().collect();
10902            if lines.is_empty() {
10903                out.push_str("  No active logon sessions enumerated via WMI.\n");
10904            } else {
10905                for line in lines
10906                    .iter()
10907                    .take(max_entries)
10908                    .filter(|l| !l.trim().is_empty())
10909                {
10910                    let parts: Vec<&str> = line.trim().split('|').collect();
10911                    if parts.len() == 4 {
10912                        let logon_type = match parts[2] {
10913                            "2" => "Interactive",
10914                            "3" => "Network",
10915                            "4" => "Batch",
10916                            "5" => "Service",
10917                            "7" => "Unlock",
10918                            "8" => "NetworkCleartext",
10919                            "9" => "NewCredentials",
10920                            "10" => "RemoteInteractive",
10921                            "11" => "CachedInteractive",
10922                            _ => "Other",
10923                        };
10924                        out.push_str(&format!(
10925                            "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
10926                            parts[0], logon_type, parts[1], parts[3]
10927                        ));
10928                    }
10929                }
10930            }
10931        } else {
10932            out.push_str("  Active logon session data unavailable from WMI.\n");
10933        }
10934    }
10935
10936    #[cfg(not(target_os = "windows"))]
10937    {
10938        out.push_str("=== Logged-in Users (who) ===\n");
10939        let who = Command::new("who")
10940            .output()
10941            .ok()
10942            .and_then(|o| String::from_utf8(o.stdout).ok())
10943            .unwrap_or_default();
10944        out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
10945    }
10946
10947    Ok(out.trim_end().to_string())
10948}
10949
10950async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
10951    let mut out = String::from("Host inspection: disk_benchmark\n\n");
10952    let mut final_path = path;
10953
10954    if !final_path.exists() {
10955        if let Ok(current_exe) = std::env::current_exe() {
10956            out.push_str(&format!(
10957                "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
10958                final_path.display()
10959            ));
10960            final_path = current_exe;
10961        } else {
10962            return Err(format!("Target not found: {}", final_path.display()));
10963        }
10964    }
10965
10966    let target = if final_path.is_dir() {
10967        // Find a representative file to read
10968        let mut target_file = final_path.join("Cargo.toml");
10969        if !target_file.exists() {
10970            target_file = final_path.join("README.md");
10971        }
10972        if !target_file.exists() {
10973            return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
10974        }
10975        target_file
10976    } else {
10977        final_path
10978    };
10979
10980    out.push_str(&format!("Target: {}\n", target.display()));
10981    out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
10982
10983    #[cfg(target_os = "windows")]
10984    {
10985        let script = format!(
10986            r#"
10987$target = "{}"
10988if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
10989
10990$diskQueue = @()
10991$readStats = @()
10992$startTime = Get-Date
10993$duration = 5
10994
10995# Background reader job
10996$job = Start-Job -ScriptBlock {{
10997    param($t, $d)
10998    $stop = (Get-Date).AddSeconds($d)
10999    while ((Get-Date) -lt $stop) {{
11000        try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
11001    }}
11002}} -ArgumentList $target, $duration
11003
11004# Metrics collector loop
11005$stopTime = (Get-Date).AddSeconds($duration)
11006while ((Get-Date) -lt $stopTime) {{
11007    $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
11008    if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
11009    
11010    $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
11011    if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
11012    
11013    Start-Sleep -Milliseconds 250
11014}}
11015
11016Stop-Job $job
11017Receive-Job $job | Out-Null
11018Remove-Job $job
11019
11020$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
11021$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
11022$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
11023
11024"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
11025"#,
11026            target.display()
11027        );
11028
11029        let output = Command::new("powershell")
11030            .args(["-NoProfile", "-Command", &script])
11031            .output()
11032            .map_err(|e| format!("Benchmark failed: {e}"))?;
11033
11034        let raw = String::from_utf8_lossy(&output.stdout);
11035        let text = raw.trim();
11036
11037        if text.starts_with("ERROR") {
11038            return Err(text.to_string());
11039        }
11040
11041        let mut lines = text.lines();
11042        if let Some(metrics_line) = lines.next() {
11043            let parts: Vec<&str> = metrics_line.split('|').collect();
11044            let mut avg_q = "unknown".to_string();
11045            let mut max_q = "unknown".to_string();
11046            let mut avg_r = "unknown".to_string();
11047
11048            for p in parts {
11049                if let Some((k, v)) = p.split_once(':') {
11050                    match k {
11051                        "AVG_Q" => avg_q = v.to_string(),
11052                        "MAX_Q" => max_q = v.to_string(),
11053                        "AVG_R" => avg_r = v.to_string(),
11054                        _ => {}
11055                    }
11056                }
11057            }
11058
11059            out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
11060            out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
11061            out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
11062            out.push_str(&format!("- Disk Throughput (Avg):  {} reads/sec\n", avg_r));
11063            out.push_str("\nVerdict: ");
11064            let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
11065            if q_num > 1.0 {
11066                out.push_str(
11067                    "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
11068                );
11069            } else if q_num > 0.1 {
11070                out.push_str("MODERATE LOAD — significant I/O pressure detected.");
11071            } else {
11072                out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
11073            }
11074        }
11075    }
11076
11077    #[cfg(not(target_os = "windows"))]
11078    {
11079        out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
11080        out.push_str("Generic disk load simulated.\n");
11081    }
11082
11083    Ok(out)
11084}
11085
11086fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
11087    let mut out = String::from("Host inspection: permissions\n\n");
11088    out.push_str(&format!(
11089        "Auditing access control for: {}\n\n",
11090        path.display()
11091    ));
11092
11093    #[cfg(target_os = "windows")]
11094    {
11095        let script = format!(
11096            "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
11097            path.display()
11098        );
11099        let output = Command::new("powershell")
11100            .args(["-NoProfile", "-Command", &script])
11101            .output()
11102            .map_err(|e| format!("ACL check failed: {e}"))?;
11103
11104        let text = String::from_utf8_lossy(&output.stdout);
11105        if text.trim().is_empty() {
11106            out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
11107        } else {
11108            out.push_str("=== Windows NTFS Permissions ===\n");
11109            out.push_str(&text);
11110        }
11111    }
11112
11113    #[cfg(not(target_os = "windows"))]
11114    {
11115        let output = Command::new("ls")
11116            .args(["-ld", &path.to_string_lossy()])
11117            .output()
11118            .map_err(|e| format!("ls check failed: {e}"))?;
11119        out.push_str("=== Unix File Permissions ===\n");
11120        out.push_str(&String::from_utf8_lossy(&output.stdout));
11121    }
11122
11123    Ok(out.trim_end().to_string())
11124}
11125
11126fn inspect_login_history(max_entries: usize) -> Result<String, String> {
11127    let mut out = String::from("Host inspection: login_history\n\n");
11128
11129    #[cfg(target_os = "windows")]
11130    {
11131        out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
11132        out.push_str("Note: This typically requires Administrator elevation.\n\n");
11133
11134        let n = max_entries.clamp(1, 50);
11135        let script = format!(
11136            r#"try {{
11137    $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
11138    $events | ForEach-Object {{
11139        $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
11140        # Extract target user name from the XML/Properties if possible
11141        $user = $_.Properties[5].Value
11142        $type = $_.Properties[8].Value
11143        "[$time] User: $user | Type: $type"
11144    }}
11145}} catch {{ "ERROR:" + $_.Exception.Message }}"#
11146        );
11147
11148        let output = Command::new("powershell")
11149            .args(["-NoProfile", "-Command", &script])
11150            .output()
11151            .map_err(|e| format!("Login history query failed: {e}"))?;
11152
11153        let text = String::from_utf8_lossy(&output.stdout);
11154        if text.starts_with("ERROR:") {
11155            out.push_str(&format!("Unable to query Security Log: {}\n", text));
11156        } else if text.trim().is_empty() {
11157            out.push_str("No recent logon events found or access denied.\n");
11158        } else {
11159            out.push_str("=== Recent Logons (Event ID 4624) ===\n");
11160            out.push_str(&text);
11161        }
11162    }
11163
11164    #[cfg(not(target_os = "windows"))]
11165    {
11166        let output = Command::new("last")
11167            .args(["-n", &max_entries.to_string()])
11168            .output()
11169            .map_err(|e| format!("last command failed: {e}"))?;
11170        out.push_str("=== Unix Login History (last) ===\n");
11171        out.push_str(&String::from_utf8_lossy(&output.stdout));
11172    }
11173
11174    Ok(out.trim_end().to_string())
11175}
11176
11177fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11178    let mut out = String::from("Host inspection: share_access\n\n");
11179    out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11180
11181    #[cfg(target_os = "windows")]
11182    {
11183        let script = format!(
11184            r#"
11185$p = '{}'
11186$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11187if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11188    $res.Reachable = $true
11189    try {{
11190        $null = Get-ChildItem -Path $p -ErrorAction Stop
11191        $res.Readable = $true
11192    }} catch {{
11193        $res.Error = $_.Exception.Message
11194    }}
11195}} else {{
11196    $res.Error = "Server unreachable (Ping failed)"
11197}}
11198"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11199            path.display()
11200        );
11201
11202        let output = Command::new("powershell")
11203            .args(["-NoProfile", "-Command", &script])
11204            .output()
11205            .map_err(|e| format!("Share test failed: {e}"))?;
11206
11207        let text = String::from_utf8_lossy(&output.stdout);
11208        out.push_str("=== Share Triage Results ===\n");
11209        out.push_str(&text);
11210    }
11211
11212    #[cfg(not(target_os = "windows"))]
11213    {
11214        out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11215    }
11216
11217    Ok(out.trim_end().to_string())
11218}
11219
11220fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11221    let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11222    out.push_str(&format!("Issue: {}\n\n", issue));
11223    out.push_str("Proposed Remediation Steps:\n");
11224    out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11225    out.push_str("   `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11226    out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11227    out.push_str("   `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11228    out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11229    out.push_str("   `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11230    out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11231    out.push_str(
11232        "   `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11233    );
11234
11235    Ok(out)
11236}
11237
11238fn inspect_registry_audit() -> Result<String, String> {
11239    let mut out = String::from("Host inspection: registry_audit\n\n");
11240    out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11241
11242    #[cfg(target_os = "windows")]
11243    {
11244        let script = r#"
11245$findings = @()
11246
11247# 1. Image File Execution Options (Debugger Hijacking)
11248$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11249if (Test-Path $ifeo) {
11250    Get-ChildItem $ifeo | ForEach-Object {
11251        $p = Get-ItemProperty $_.PSPath
11252        if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11253    }
11254}
11255
11256# 2. Winlogon Shell Integrity
11257$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11258$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11259if ($shell -and $shell -ne "explorer.exe") {
11260    $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11261}
11262
11263# 3. Session Manager BootExecute
11264$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11265$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11266if ($boot -and $boot -notcontains "autocheck autochk *") {
11267    $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11268}
11269
11270if ($findings.Count -eq 0) {
11271    "PASS: No common registry hijacking or shell overrides detected."
11272} else {
11273    $findings -join "`n"
11274}
11275"#;
11276        let output = Command::new("powershell")
11277            .args(["-NoProfile", "-Command", &script])
11278            .output()
11279            .map_err(|e| format!("Registry audit failed: {e}"))?;
11280
11281        let text = String::from_utf8_lossy(&output.stdout);
11282        out.push_str("=== Persistence & Integrity Check ===\n");
11283        out.push_str(&text);
11284    }
11285
11286    #[cfg(not(target_os = "windows"))]
11287    {
11288        out.push_str("Registry auditing is specific to Windows environments.\n");
11289    }
11290
11291    Ok(out.trim_end().to_string())
11292}
11293
11294fn inspect_thermal() -> Result<String, String> {
11295    let mut out = String::from("Host inspection: thermal\n\n");
11296    out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11297
11298    #[cfg(target_os = "windows")]
11299    {
11300        let script = r#"
11301$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11302if ($thermal) {
11303    $thermal | ForEach-Object {
11304        $temp = [math]::Round(($_.Temperature - 273.15), 1)
11305        "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11306    }
11307} else {
11308    "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11309    $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11310    "Current CPU Load: $throttling%"
11311}
11312"#;
11313        let output = Command::new("powershell")
11314            .args(["-NoProfile", "-Command", script])
11315            .output()
11316            .map_err(|e| format!("Thermal check failed: {e}"))?;
11317        out.push_str("=== Windows Thermal State ===\n");
11318        out.push_str(&String::from_utf8_lossy(&output.stdout));
11319    }
11320
11321    #[cfg(not(target_os = "windows"))]
11322    {
11323        out.push_str(
11324            "Thermal inspection is currently optimized for Windows performance counters.\n",
11325        );
11326    }
11327
11328    Ok(out.trim_end().to_string())
11329}
11330
11331fn inspect_activation() -> Result<String, String> {
11332    let mut out = String::from("Host inspection: activation\n\n");
11333    out.push_str("Auditing Windows activation and license state...\n\n");
11334
11335    #[cfg(target_os = "windows")]
11336    {
11337        let script = r#"
11338$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11339$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
11340"Status: $($xpr.Trim())"
11341"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
11342"#;
11343        let output = Command::new("powershell")
11344            .args(["-NoProfile", "-Command", script])
11345            .output()
11346            .map_err(|e| format!("Activation check failed: {e}"))?;
11347        out.push_str("=== Windows License Report ===\n");
11348        out.push_str(&String::from_utf8_lossy(&output.stdout));
11349    }
11350
11351    #[cfg(not(target_os = "windows"))]
11352    {
11353        out.push_str("Windows activation check is specific to the Windows platform.\n");
11354    }
11355
11356    Ok(out.trim_end().to_string())
11357}
11358
11359fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
11360    let mut out = String::from("Host inspection: patch_history\n\n");
11361    out.push_str(&format!(
11362        "Listing the last {} installed Windows updates (KBs)...\n\n",
11363        max_entries
11364    ));
11365
11366    #[cfg(target_os = "windows")]
11367    {
11368        let n = max_entries.clamp(1, 50);
11369        let script = format!(
11370            "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
11371            n
11372        );
11373        let output = Command::new("powershell")
11374            .args(["-NoProfile", "-Command", &script])
11375            .output()
11376            .map_err(|e| format!("Patch history query failed: {e}"))?;
11377        out.push_str("=== Recent HotFixes (KBs) ===\n");
11378        out.push_str(&String::from_utf8_lossy(&output.stdout));
11379    }
11380
11381    #[cfg(not(target_os = "windows"))]
11382    {
11383        out.push_str("Patch history is currently focused on Windows HotFixes.\n");
11384    }
11385
11386    Ok(out.trim_end().to_string())
11387}
11388
11389// ── ad_user ──────────────────────────────────────────────────────────────────
11390
11391fn inspect_ad_user(identity: &str) -> Result<String, String> {
11392    let mut out = String::from("Host inspection: ad_user\n\n");
11393    let ident = identity.trim();
11394    if ident.is_empty() {
11395        out.push_str("Status: No identity specified. Performing self-discovery...\n");
11396        #[cfg(target_os = "windows")]
11397        {
11398            let script = r#"
11399$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
11400"USER: " + $u.Name
11401"SID: " + $u.User.Value
11402"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
11403"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
11404"#;
11405            let output = Command::new("powershell")
11406                .args(["-NoProfile", "-Command", script])
11407                .output()
11408                .ok();
11409            if let Some(o) = output {
11410                out.push_str(&String::from_utf8_lossy(&o.stdout));
11411            }
11412        }
11413        return Ok(out);
11414    }
11415
11416    #[cfg(target_os = "windows")]
11417    {
11418        let script = format!(
11419            r#"
11420try {{
11421    $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
11422    "NAME: " + $u.Name
11423    "SID: " + $u.SID
11424    "ENABLED: " + $u.Enabled
11425    "EXPIRED: " + $u.PasswordExpired
11426    "LOGON: " + $u.LastLogonDate
11427    "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
11428}} catch {{
11429    # Fallback to net user if AD module is missing or fails
11430    $net = net user "{ident}" /domain 2>&1
11431    if ($LASTEXITCODE -eq 0) {{
11432        $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
11433    }} else {{
11434        "ERROR: " + $_.Exception.Message
11435    }}
11436}}"#
11437        );
11438
11439        let output = Command::new("powershell")
11440            .args(["-NoProfile", "-Command", &script])
11441            .output()
11442            .ok();
11443
11444        if let Some(o) = output {
11445            let stdout = String::from_utf8_lossy(&o.stdout);
11446            if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
11447                out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
11448            }
11449            out.push_str(&stdout);
11450        }
11451    }
11452
11453    #[cfg(not(target_os = "windows"))]
11454    {
11455        let _ = ident;
11456        out.push_str("(AD User lookup only available on Windows nodes)\n");
11457    }
11458
11459    Ok(out.trim_end().to_string())
11460}
11461
11462// ── dns_lookup ───────────────────────────────────────────────────────────────
11463
11464fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
11465    let mut out = String::from("Host inspection: dns_lookup\n\n");
11466    let target = name.trim();
11467    if target.is_empty() {
11468        return Err("Missing required target name for dns_lookup.".to_string());
11469    }
11470
11471    #[cfg(target_os = "windows")]
11472    {
11473        let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
11474        let output = Command::new("powershell")
11475            .args(["-NoProfile", "-Command", &script])
11476            .output()
11477            .ok();
11478        if let Some(o) = output {
11479            let stdout = String::from_utf8_lossy(&o.stdout);
11480            if stdout.trim().is_empty() {
11481                out.push_str(&format!("No {record_type} records found for {target}.\n"));
11482            } else {
11483                out.push_str(&stdout);
11484            }
11485        }
11486    }
11487
11488    #[cfg(not(target_os = "windows"))]
11489    {
11490        let output = Command::new("dig")
11491            .args([target, record_type, "+short"])
11492            .output()
11493            .ok();
11494        if let Some(o) = output {
11495            out.push_str(&String::from_utf8_lossy(&o.stdout));
11496        }
11497    }
11498
11499    Ok(out.trim_end().to_string())
11500}
11501
11502// ── hyperv ───────────────────────────────────────────────────────────────────
11503
11504#[cfg(target_os = "windows")]
11505fn ps_exec(script: &str) -> String {
11506    Command::new("powershell")
11507        .args(["-NoProfile", "-NonInteractive", "-Command", script])
11508        .output()
11509        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
11510        .unwrap_or_default()
11511}
11512
11513fn inspect_hyperv() -> Result<String, String> {
11514    #[cfg(target_os = "windows")]
11515    {
11516        let mut findings: Vec<String> = Vec::new();
11517        let mut out = String::new();
11518
11519        // --- Hyper-V role / VMMS service state ---
11520        let ps_role = r#"
11521$vmms = Get-Service -Name vmms -ErrorAction SilentlyContinue
11522$feature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction SilentlyContinue
11523$hostInfo = Get-VMHost -ErrorAction SilentlyContinue
11524$ram = (Get-CimInstance Win32_PhysicalMemory -ErrorAction SilentlyContinue | Measure-Object -Property Capacity -Sum).Sum
11525"VMMS:{0}|FeatureState:{1}|HostName:{2}|HostRAMBytes:{3}" -f `
11526    $(if ($vmms) { "$($vmms.Status)|$($vmms.StartType)" } else { "NotFound|Unknown" }),
11527    $(if ($feature) { $feature.State } else { "Unknown" }),
11528    $(if ($hostInfo) { $hostInfo.ComputerName } else { $env:COMPUTERNAME }),
11529    $(if ($ram) { $ram } else { "0" })
11530"#;
11531        let role_out = ps_exec(ps_role);
11532        out.push_str("=== Hyper-V role state ===\n");
11533
11534        let mut vmms_running = false;
11535        let mut host_ram_bytes: u64 = 0;
11536
11537        if let Some(line) = role_out.lines().find(|l| l.contains("VMMS:")) {
11538            let kv: std::collections::HashMap<&str, &str> = line
11539                .split('|')
11540                .filter_map(|p| {
11541                    let mut it = p.splitn(2, ':');
11542                    Some((it.next()?, it.next()?))
11543                })
11544                .collect();
11545            let vmms_status = kv.get("VMMS").copied().unwrap_or("Unknown");
11546            let feature_state = kv.get("FeatureState").copied().unwrap_or("Unknown");
11547            let host_name = kv.get("HostName").copied().unwrap_or("Unknown");
11548            host_ram_bytes = kv
11549                .get("HostRAMBytes")
11550                .and_then(|v| v.parse().ok())
11551                .unwrap_or(0);
11552
11553            let hyperv_installed = feature_state.eq_ignore_ascii_case("enabled");
11554            vmms_running = vmms_status.starts_with("Running");
11555
11556            out.push_str(&format!("- Host: {host_name}\n"));
11557            out.push_str(&format!(
11558                "- Hyper-V feature: {}\n",
11559                if hyperv_installed {
11560                    "Enabled"
11561                } else {
11562                    "Not installed"
11563                }
11564            ));
11565            out.push_str(&format!("- VMMS service: {vmms_status}\n"));
11566            if host_ram_bytes > 0 {
11567                out.push_str(&format!(
11568                    "- Host physical RAM: {} GB\n",
11569                    host_ram_bytes / 1_073_741_824
11570                ));
11571            }
11572
11573            if !hyperv_installed {
11574                findings.push(
11575                    "Hyper-V is not installed on this machine. Enable the Microsoft-Hyper-V-All feature to use virtualization.".into(),
11576                );
11577            } else if !vmms_running {
11578                findings.push(
11579                    "Hyper-V is installed but the Virtual Machine Management Service (vmms) is not running — VMs cannot start until the service is active.".into(),
11580                );
11581            }
11582        } else {
11583            out.push_str("- Could not determine Hyper-V role state\n");
11584            findings.push("Hyper-V does not appear to be installed on this machine.".into());
11585        }
11586
11587        // --- Virtual machines ---
11588        out.push_str("\n=== Virtual machines ===\n");
11589        if vmms_running {
11590            let ps_vms = r#"
11591Get-VM -ErrorAction SilentlyContinue | ForEach-Object {
11592    $ram_gb = [math]::Round($_.MemoryAssigned / 1GB, 2)
11593    "VM:{0}|State:{1}|CPU:{2}%|RAM:{3}GB|Uptime:{4}|Status:{5}|Generation:{6}" -f `
11594        $_.Name, $_.State, $_.CPUUsage, $ram_gb,
11595        $(if ($_.Uptime.TotalSeconds -gt 0) { "$($_.Uptime.Hours)h$($_.Uptime.Minutes)m" } else { "Off" }),
11596        $_.Status, $_.Generation
11597}
11598"#;
11599            let vms_out = ps_exec(ps_vms);
11600            let vm_lines: Vec<&str> = vms_out.lines().filter(|l| l.starts_with("VM:")).collect();
11601
11602            if vm_lines.is_empty() {
11603                out.push_str("- No virtual machines found on this host\n");
11604            } else {
11605                let mut total_ram_bytes: u64 = 0;
11606                let mut saved_vms: Vec<String> = Vec::new();
11607                for line in &vm_lines {
11608                    let kv: std::collections::HashMap<&str, &str> = line
11609                        .split('|')
11610                        .filter_map(|p| {
11611                            let mut it = p.splitn(2, ':');
11612                            Some((it.next()?, it.next()?))
11613                        })
11614                        .collect();
11615                    let name = kv.get("VM").copied().unwrap_or("Unknown");
11616                    let state = kv.get("State").copied().unwrap_or("Unknown");
11617                    let cpu = kv.get("CPU").copied().unwrap_or("0").trim_end_matches('%');
11618                    let ram = kv.get("RAM").copied().unwrap_or("0").trim_end_matches("GB");
11619                    let uptime = kv.get("Uptime").copied().unwrap_or("Off");
11620                    let status = kv.get("Status").copied().unwrap_or("");
11621                    let gen = kv.get("Generation").copied().unwrap_or("?");
11622
11623                    if let Ok(r) = ram.parse::<f64>() {
11624                        total_ram_bytes += (r * 1_073_741_824.0) as u64;
11625                    }
11626                    if state.eq_ignore_ascii_case("Saved") {
11627                        saved_vms.push(name.to_string());
11628                    }
11629
11630                    out.push_str(&format!(
11631                        "- {name} | State: {state} | CPU: {cpu}% | RAM: {ram} GB | Uptime: {uptime} | Gen{gen}\n"
11632                    ));
11633                    if !status.is_empty() && !status.eq_ignore_ascii_case("Operating normally") {
11634                        out.push_str(&format!("  Status: {status}\n"));
11635                    }
11636                }
11637
11638                out.push_str(&format!("\n- Total VMs: {}\n", vm_lines.len()));
11639                if total_ram_bytes > 0 && host_ram_bytes > 0 {
11640                    let pct = (total_ram_bytes * 100) / host_ram_bytes;
11641                    out.push_str(&format!(
11642                        "- Total VM RAM assigned: {} GB ({pct}% of host RAM)\n",
11643                        total_ram_bytes / 1_073_741_824
11644                    ));
11645                    if pct > 90 {
11646                        findings.push(format!(
11647                            "VM RAM assignment is at {pct}% of host physical RAM — the host may be under severe memory pressure if all VMs run simultaneously."
11648                        ));
11649                    }
11650                }
11651                if !saved_vms.is_empty() {
11652                    findings.push(format!(
11653                        "VMs in Saved state (consuming disk space for checkpoint): {} — resume or delete to free space.",
11654                        saved_vms.join(", ")
11655                    ));
11656                }
11657            }
11658        } else {
11659            out.push_str("- VMMS not running — cannot enumerate VMs\n");
11660        }
11661
11662        // --- VM network switches ---
11663        out.push_str("\n=== VM network switches ===\n");
11664        if vmms_running {
11665            let ps_switches = r#"
11666Get-VMSwitch -ErrorAction SilentlyContinue | ForEach-Object {
11667    "Switch:{0}|Type:{1}|Adapter:{2}" -f `
11668        $_.Name, $_.SwitchType,
11669        $(if ($_.NetAdapterInterfaceDescription) { $_.NetAdapterInterfaceDescription } else { "N/A" })
11670}
11671"#;
11672            let sw_out = ps_exec(ps_switches);
11673            let switch_lines: Vec<&str> = sw_out
11674                .lines()
11675                .filter(|l| l.starts_with("Switch:"))
11676                .collect();
11677
11678            if switch_lines.is_empty() {
11679                out.push_str("- No VM switches configured\n");
11680            } else {
11681                for line in &switch_lines {
11682                    let kv: std::collections::HashMap<&str, &str> = line
11683                        .split('|')
11684                        .filter_map(|p| {
11685                            let mut it = p.splitn(2, ':');
11686                            Some((it.next()?, it.next()?))
11687                        })
11688                        .collect();
11689                    let name = kv.get("Switch").copied().unwrap_or("Unknown");
11690                    let sw_type = kv.get("Type").copied().unwrap_or("Unknown");
11691                    let adapter = kv.get("Adapter").copied().unwrap_or("N/A");
11692                    out.push_str(&format!("- {name} | Type: {sw_type} | NIC: {adapter}\n"));
11693                }
11694            }
11695        } else {
11696            out.push_str("- VMMS not running — cannot enumerate switches\n");
11697        }
11698
11699        // --- VM checkpoints ---
11700        out.push_str("\n=== VM checkpoints ===\n");
11701        if vmms_running {
11702            let ps_checkpoints = r#"
11703$all = Get-VMCheckpoint -VMName * -ErrorAction SilentlyContinue
11704if ($all) {
11705    $all | ForEach-Object {
11706        "Checkpoint:{0}|VM:{1}|Created:{2}|Type:{3}" -f `
11707            $_.Name, $_.VMName,
11708            $_.CreationTime.ToString("yyyy-MM-dd HH:mm"),
11709            $_.SnapshotType
11710    }
11711} else {
11712    "NONE"
11713}
11714"#;
11715            let cp_out = ps_exec(ps_checkpoints);
11716            if cp_out.trim() == "NONE" || cp_out.trim().is_empty() {
11717                out.push_str("- No checkpoints found\n");
11718            } else {
11719                let cp_lines: Vec<&str> = cp_out
11720                    .lines()
11721                    .filter(|l| l.starts_with("Checkpoint:"))
11722                    .collect();
11723                let mut per_vm: std::collections::HashMap<&str, usize> =
11724                    std::collections::HashMap::new();
11725                for line in &cp_lines {
11726                    let kv: std::collections::HashMap<&str, &str> = line
11727                        .split('|')
11728                        .filter_map(|p| {
11729                            let mut it = p.splitn(2, ':');
11730                            Some((it.next()?, it.next()?))
11731                        })
11732                        .collect();
11733                    let cp_name = kv.get("Checkpoint").copied().unwrap_or("Unknown");
11734                    let vm_name = kv.get("VM").copied().unwrap_or("Unknown");
11735                    let created = kv.get("Created").copied().unwrap_or("");
11736                    let cp_type = kv.get("Type").copied().unwrap_or("");
11737                    out.push_str(&format!(
11738                        "- [{vm_name}] {cp_name} | Created: {created} | Type: {cp_type}\n"
11739                    ));
11740                    *per_vm.entry(vm_name).or_insert(0) += 1;
11741                }
11742                for (vm, count) in &per_vm {
11743                    if *count >= 3 {
11744                        findings.push(format!(
11745                            "VM '{vm}' has {count} checkpoints — excessive checkpoints grow the VHDX chain and degrade disk performance. Delete unneeded checkpoints."
11746                        ));
11747                    }
11748                }
11749            }
11750        } else {
11751            out.push_str("- VMMS not running — cannot enumerate checkpoints\n");
11752        }
11753
11754        let mut result = String::from("Host inspection: hyperv\n\n=== Findings ===\n");
11755        if findings.is_empty() {
11756            result.push_str("- No Hyper-V health issues detected.\n");
11757        } else {
11758            for f in &findings {
11759                result.push_str(&format!("- Finding: {f}\n"));
11760            }
11761        }
11762        result.push('\n');
11763        result.push_str(&out);
11764        return Ok(result.trim_end().to_string());
11765    }
11766
11767    #[cfg(not(target_os = "windows"))]
11768    Ok(
11769        "Host inspection: hyperv\n\n=== Findings ===\n- Hyper-V inspection is Windows-only.\n"
11770            .into(),
11771    )
11772}
11773
11774// ── ip_config ────────────────────────────────────────────────────────────────
11775
11776fn inspect_ip_config() -> Result<String, String> {
11777    let mut out = String::from("Host inspection: ip_config\n\n");
11778
11779    #[cfg(target_os = "windows")]
11780    {
11781        let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
11782            $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
11783            '\\n  Status: ' + $_.NetAdapter.Status + \
11784            '\\n  Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
11785            '\\n  DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
11786            '\\n  DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11787            '\\n  IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11788            '\\n  DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
11789        }";
11790        let output = Command::new("powershell")
11791            .args(["-NoProfile", "-Command", script])
11792            .output()
11793            .ok();
11794        if let Some(o) = output {
11795            out.push_str(&String::from_utf8_lossy(&o.stdout));
11796        }
11797    }
11798
11799    #[cfg(not(target_os = "windows"))]
11800    {
11801        let output = Command::new("ip").args(["addr", "show"]).output().ok();
11802        if let Some(o) = output {
11803            out.push_str(&String::from_utf8_lossy(&o.stdout));
11804        }
11805    }
11806
11807    Ok(out.trim_end().to_string())
11808}
11809
11810// ── event_query ──────────────────────────────────────────────────────────────
11811
11812fn inspect_event_query(
11813    event_id: Option<u32>,
11814    log_name: Option<&str>,
11815    source: Option<&str>,
11816    hours: u32,
11817    level: Option<&str>,
11818    max_entries: usize,
11819) -> Result<String, String> {
11820    #[cfg(target_os = "windows")]
11821    {
11822        let mut findings: Vec<String> = Vec::new();
11823
11824        // Build the PowerShell filter hash
11825        let log = log_name.unwrap_or("*");
11826        let cap = max_entries.min(50);
11827
11828        // Level mapping: Error=2, Warning=3, Information=4
11829        let level_filter = match level.map(|l| l.to_lowercase()).as_deref() {
11830            Some("error") | Some("errors") => Some(2u8),
11831            Some("warning") | Some("warnings") | Some("warn") => Some(3u8),
11832            Some("information") | Some("info") => Some(4u8),
11833            _ => None,
11834        };
11835
11836        // Build filter hashtable entries
11837        let mut filter_parts = vec![format!("StartTime = (Get-Date).AddHours(-{hours})")];
11838        if log != "*" {
11839            filter_parts.push(format!("LogName = '{log}'"));
11840        }
11841        if let Some(id) = event_id {
11842            filter_parts.push(format!("Id = {id}"));
11843        }
11844        if let Some(src) = source {
11845            filter_parts.push(format!("ProviderName = '{src}'"));
11846        }
11847        if let Some(lvl) = level_filter {
11848            filter_parts.push(format!("Level = {lvl}"));
11849        }
11850
11851        let filter_ht = filter_parts.join("; ");
11852
11853        let ps = format!(
11854            r#"
11855$filter = @{{ {filter_ht} }}
11856try {{
11857    $events = Get-WinEvent -FilterHashtable $filter -MaxEvents {cap} -ErrorAction Stop |
11858        Select-Object TimeCreated, Id, LevelDisplayName, ProviderName,
11859            @{{N='Msg';E={{ ($_.Message -split "`n")[0] }}}}
11860    if ($events) {{
11861        $events | ForEach-Object {{
11862            "TIME:{{0}}|ID:{{1}}|LEVEL:{{2}}|SOURCE:{{3}}|MSG:{{4}}" -f `
11863                $_.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss"),
11864                $_.Id, $_.LevelDisplayName, $_.ProviderName,
11865                ($_.Msg -replace '\|','/')
11866        }}
11867    }} else {{
11868        "NONE"
11869    }}
11870}} catch {{
11871    "ERROR:$($_.Exception.Message)"
11872}}
11873"#
11874        );
11875
11876        let raw = ps_exec(&ps);
11877        let lines: Vec<&str> = raw.lines().collect();
11878
11879        // Build query description for header
11880        let mut query_desc = format!("last {hours}h");
11881        if let Some(id) = event_id {
11882            query_desc.push_str(&format!(", Event ID {id}"));
11883        }
11884        if let Some(src) = source {
11885            query_desc.push_str(&format!(", source '{src}'"));
11886        }
11887        if log != "*" {
11888            query_desc.push_str(&format!(", log '{log}'"));
11889        }
11890        if let Some(l) = level {
11891            query_desc.push_str(&format!(", level '{l}'"));
11892        }
11893
11894        let mut out = format!("=== Event query: {query_desc} ===\n");
11895
11896        if lines
11897            .iter()
11898            .any(|l| l.trim() == "NONE" || l.trim().is_empty())
11899        {
11900            out.push_str("- No matching events found.\n");
11901        } else if let Some(err_line) = lines.iter().find(|l| l.starts_with("ERROR:")) {
11902            let msg = err_line.trim_start_matches("ERROR:").trim();
11903            if is_event_query_no_results_message(msg) {
11904                out.push_str("- No matching events found.\n");
11905            } else {
11906                out.push_str(&format!("- Query error: {msg}\n"));
11907                findings.push(format!("Event query failed: {msg}"));
11908            }
11909        } else {
11910            let event_lines: Vec<&str> = lines
11911                .iter()
11912                .filter(|l| l.starts_with("TIME:"))
11913                .copied()
11914                .collect();
11915            if event_lines.is_empty() {
11916                out.push_str("- No matching events found.\n");
11917            } else {
11918                // Tally by level for findings
11919                let mut error_count = 0usize;
11920                let mut warning_count = 0usize;
11921
11922                for line in &event_lines {
11923                    let kv: std::collections::HashMap<&str, &str> = line
11924                        .split('|')
11925                        .filter_map(|p| {
11926                            let mut it = p.splitn(2, ':');
11927                            Some((it.next()?, it.next()?))
11928                        })
11929                        .collect();
11930                    let time = kv.get("TIME").copied().unwrap_or("?");
11931                    let id = kv.get("ID").copied().unwrap_or("?");
11932                    let lvl = kv.get("LEVEL").copied().unwrap_or("?");
11933                    let src = kv.get("SOURCE").copied().unwrap_or("?");
11934                    let msg = kv.get("MSG").copied().unwrap_or("").trim();
11935
11936                    // Truncate long messages
11937                    let msg_display = if msg.len() > 120 {
11938                        format!("{}…", &msg[..120])
11939                    } else {
11940                        msg.to_string()
11941                    };
11942
11943                    out.push_str(&format!(
11944                        "- [{time}] ID {id} | {lvl} | {src}\n  {msg_display}\n"
11945                    ));
11946
11947                    if lvl.eq_ignore_ascii_case("error") || lvl.eq_ignore_ascii_case("critical") {
11948                        error_count += 1;
11949                    } else if lvl.eq_ignore_ascii_case("warning") {
11950                        warning_count += 1;
11951                    }
11952                }
11953
11954                out.push_str(&format!(
11955                    "\n- Total shown: {} event(s)\n",
11956                    event_lines.len()
11957                ));
11958
11959                if error_count > 0 {
11960                    findings.push(format!(
11961                        "{error_count} Error/Critical event(s) found in the {query_desc} window — review the entries above for root cause."
11962                    ));
11963                }
11964                if warning_count > 5 {
11965                    findings.push(format!(
11966                        "{warning_count} Warning events found — elevated warning volume may indicate a recurring issue."
11967                    ));
11968                }
11969            }
11970        }
11971
11972        let mut result = String::from("Host inspection: event_query\n\n=== Findings ===\n");
11973        if findings.is_empty() {
11974            result.push_str("- No actionable findings from this event query.\n");
11975        } else {
11976            for f in &findings {
11977                result.push_str(&format!("- Finding: {f}\n"));
11978            }
11979        }
11980        result.push('\n');
11981        result.push_str(&out);
11982        return Ok(result.trim_end().to_string());
11983    }
11984
11985    #[cfg(not(target_os = "windows"))]
11986    {
11987        let _ = (event_id, log_name, source, hours, level, max_entries);
11988        Ok("Host inspection: event_query\n\n=== Findings ===\n- Event log query is Windows-only.\n".into())
11989    }
11990}
11991
11992// ── app_crashes ───────────────────────────────────────────────────────────────
11993
11994fn inspect_app_crashes(process_filter: Option<&str>, max_entries: usize) -> Result<String, String> {
11995    let n = max_entries.clamp(5, 50);
11996    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
11997    let mut findings: Vec<String> = Vec::new();
11998    #[cfg_attr(not(target_os = "windows"), allow(unused_mut))]
11999    let mut sections = String::new();
12000
12001    #[cfg(target_os = "windows")]
12002    {
12003        let proc_filter_ps = match process_filter {
12004            Some(proc) => format!(
12005                "| Where-Object {{ $_.Message -match [regex]::Escape('{}') }}",
12006                proc.replace('\'', "''")
12007            ),
12008            None => String::new(),
12009        };
12010
12011        let ps = format!(
12012            r#"
12013$results = @()
12014try {{
12015    $events = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue {proc_filter_ps}
12016    if ($events) {{
12017        foreach ($e in $events) {{
12018            $msg  = $e.Message
12019            $app  = if ($msg -match 'Faulting application name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ 'Unknown' }}
12020            $ver  = if ($msg -match 'Faulting application version: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12021            $mod  = if ($msg -match 'Faulting module name: ([^\r\n,]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12022            $exc  = if ($msg -match 'Exception code: (0x[0-9a-fA-F]+)') {{ $Matches[1].Trim() }} else {{ '' }}
12023            $type = if ($e.Id -eq 1002) {{ 'HANG' }} else {{ 'CRASH' }}
12024            $results += "$($e.TimeCreated.ToString('yyyy-MM-dd HH:mm'))|$type|$app|$ver|$mod|$exc"
12025        }}
12026        $results
12027    }} else {{ 'NONE' }}
12028}} catch {{ 'ERROR:' + $_.Exception.Message }}
12029"#
12030        );
12031
12032        let raw = ps_exec(&ps);
12033        let text = raw.trim();
12034
12035        // WER archive count (non-blocking best-effort)
12036        let wer_ps = r#"
12037$wer = "$env:LOCALAPPDATA\Microsoft\Windows\WER"
12038$count = 0
12039if (Test-Path $wer) {
12040    $count = (Get-ChildItem -Path $wer -Recurse -Filter '*.wer' -ErrorAction SilentlyContinue).Count
12041}
12042$count
12043"#;
12044        let wer_count: usize = ps_exec(wer_ps).trim().parse().unwrap_or(0);
12045
12046        if text == "NONE" {
12047            sections.push_str("=== Application crashes ===\n- No application crashes or hangs in recent event log.\n");
12048        } else if text.starts_with("ERROR:") {
12049            let msg = text.trim_start_matches("ERROR:").trim();
12050            sections.push_str(&format!(
12051                "=== Application crashes ===\n- Unable to query Application event log: {msg}\n"
12052            ));
12053        } else {
12054            let events: Vec<&str> = text.lines().filter(|l| l.contains('|')).collect();
12055            let crash_count = events
12056                .iter()
12057                .filter(|l| l.splitn(3, '|').nth(1) == Some("CRASH"))
12058                .count();
12059            let hang_count = events
12060                .iter()
12061                .filter(|l| l.splitn(3, '|').nth(1) == Some("HANG"))
12062                .count();
12063
12064            // Tally crashes per app
12065            let mut app_counts: std::collections::HashMap<String, usize> =
12066                std::collections::HashMap::new();
12067            for line in &events {
12068                let parts: Vec<&str> = line.splitn(6, '|').collect();
12069                if parts.len() >= 3 {
12070                    *app_counts.entry(parts[2].to_string()).or_insert(0) += 1;
12071                }
12072            }
12073
12074            if crash_count > 0 {
12075                findings.push(format!(
12076                    "{crash_count} application crash event(s) — review below for faulting app and exception code."
12077                ));
12078            }
12079            if hang_count > 0 {
12080                findings.push(format!(
12081                    "{hang_count} application hang event(s) — process stopped responding."
12082                ));
12083            }
12084            if let Some((top_app, &count)) = app_counts.iter().max_by_key(|(_, c)| *c) {
12085                if count > 1 {
12086                    findings.push(format!(
12087                        "Most-crashed application: {top_app} ({count} events) — may indicate corrupted install or incompatible module."
12088                    ));
12089                }
12090            }
12091            if wer_count > 10 {
12092                findings.push(format!(
12093                    "{wer_count} WER reports archived — elevated crash history on this machine."
12094                ));
12095            }
12096
12097            let filter_note = match process_filter {
12098                Some(p) => format!(" (filtered: {p})"),
12099                None => String::new(),
12100            };
12101            sections.push_str(&format!(
12102                "=== Application crashes and hangs{filter_note} ===\n"
12103            ));
12104
12105            for line in &events {
12106                let parts: Vec<&str> = line.splitn(6, '|').collect();
12107                if parts.len() >= 6 {
12108                    let time = parts[0];
12109                    let kind = parts[1];
12110                    let app = parts[2];
12111                    let ver = parts[3];
12112                    let module = parts[4];
12113                    let exc = parts[5];
12114                    let ver_note = if !ver.is_empty() {
12115                        format!(" v{ver}")
12116                    } else {
12117                        String::new()
12118                    };
12119                    sections.push_str(&format!("  [{time}] {kind}: {app}{ver_note}\n"));
12120                    if !module.is_empty() && module != "?" {
12121                        let exc_note = if !exc.is_empty() {
12122                            format!(" (exc {exc})")
12123                        } else {
12124                            String::new()
12125                        };
12126                        sections.push_str(&format!("    faulting module: {module}{exc_note}\n"));
12127                    } else if !exc.is_empty() {
12128                        sections.push_str(&format!("    exception: {exc}\n"));
12129                    }
12130                }
12131            }
12132            sections.push_str(&format!(
12133                "\n  Total: {crash_count} crash(es), {hang_count} hang(s)\n"
12134            ));
12135
12136            if wer_count > 0 {
12137                sections.push_str(&format!(
12138                    "\n=== Windows Error Reporting ===\n  WER archive: {wer_count} report(s) in %LOCALAPPDATA%\\Microsoft\\Windows\\WER\n"
12139                ));
12140            }
12141        }
12142    }
12143
12144    #[cfg(not(target_os = "windows"))]
12145    {
12146        let _ = (process_filter, n);
12147        sections.push_str("=== Application crashes ===\n- Windows-only (uses Application Event Log, Event IDs 1000/1002).\n");
12148    }
12149
12150    let mut result = String::from("Host inspection: app_crashes\n\n=== Findings ===\n");
12151    if findings.is_empty() {
12152        result.push_str("- No actionable findings.\n");
12153    } else {
12154        for f in &findings {
12155            result.push_str(&format!("- Finding: {f}\n"));
12156        }
12157    }
12158    result.push('\n');
12159    result.push_str(&sections);
12160    Ok(result.trim_end().to_string())
12161}
12162
12163#[cfg(target_os = "windows")]
12164fn gpu_voltage_telemetry_note() -> String {
12165    let output = Command::new("nvidia-smi")
12166        .args(["--help-query-gpu"])
12167        .output();
12168
12169    match output {
12170        Ok(o) => {
12171            let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
12172            if text.contains("\"voltage\"") || text.contains("voltage.") {
12173                "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
12174            } else {
12175                "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()
12176            }
12177        }
12178        Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
12179    }
12180}
12181
12182#[cfg(target_os = "windows")]
12183fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
12184    if raw == 0 {
12185        return None;
12186    }
12187    if raw & 0x80 != 0 {
12188        let tenths = raw & 0x7f;
12189        return Some(format!(
12190            "{:.1} V (firmware-reported WMI current voltage)",
12191            tenths as f64 / 10.0
12192        ));
12193    }
12194
12195    let legacy = match raw {
12196        1 => Some("5.0 V"),
12197        2 => Some("3.3 V"),
12198        4 => Some("2.9 V"),
12199        _ => None,
12200    }?;
12201    Some(format!(
12202        "{} (legacy WMI voltage capability flag, not live telemetry)",
12203        legacy
12204    ))
12205}
12206
12207async fn inspect_overclocker() -> Result<String, String> {
12208    let mut out = String::from("Host inspection: overclocker\n\n");
12209
12210    #[cfg(target_os = "windows")]
12211    {
12212        out.push_str(
12213            "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
12214        );
12215
12216        // 1. NVIDIA Census
12217        let nvidia = Command::new("nvidia-smi")
12218            .args([
12219                "--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",
12220                "--format=csv,noheader,nounits",
12221            ])
12222            .output();
12223
12224        if let Ok(o) = nvidia {
12225            let stdout = String::from_utf8_lossy(&o.stdout);
12226            if !stdout.trim().is_empty() {
12227                out.push_str("=== GPU SENSE (NVIDIA) ===\n");
12228                let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
12229                if parts.len() >= 10 {
12230                    out.push_str(&format!("- Model:      {}\n", parts[0]));
12231                    out.push_str(&format!("- Graphics:   {} MHz\n", parts[1]));
12232                    out.push_str(&format!("- Memory:     {} MHz\n", parts[2]));
12233                    out.push_str(&format!("- Fan Speed:  {}%\n", parts[3]));
12234                    out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
12235                    if !parts[6].eq_ignore_ascii_case("[N/A]") {
12236                        out.push_str(&format!("- Power Avg:  {} W\n", parts[6]));
12237                    }
12238                    if !parts[7].eq_ignore_ascii_case("[N/A]") {
12239                        out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
12240                    }
12241                    if !parts[8].eq_ignore_ascii_case("[N/A]") {
12242                        out.push_str(&format!("- Power Cap:  {} W requested\n", parts[8]));
12243                    }
12244                    if !parts[9].eq_ignore_ascii_case("[N/A]") {
12245                        out.push_str(&format!("- Power Enf:  {} W enforced\n", parts[9]));
12246                    }
12247                    out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
12248
12249                    if parts.len() > 10 {
12250                        let throttle_hex = parts[10];
12251                        let reasons = decode_nvidia_throttle_reasons(throttle_hex);
12252                        if !reasons.is_empty() {
12253                            out.push_str(&format!("- Throttling:  YES [Reason: {}]\n", reasons));
12254                        } else {
12255                            out.push_str("- Throttling:  None (Performance State: Max)\n");
12256                        }
12257                    }
12258                }
12259                out.push_str("\n");
12260            }
12261        }
12262
12263        out.push_str("=== VOLTAGE TELEMETRY ===\n");
12264        out.push_str(&format!(
12265            "- GPU Voltage:  {}\n\n",
12266            gpu_voltage_telemetry_note()
12267        ));
12268
12269        // 1b. Session Trends (RAM-only historians)
12270        let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
12271        let history = gpu_state.history.lock().unwrap();
12272        if history.len() >= 2 {
12273            out.push_str("=== SILICON TRENDS (Session) ===\n");
12274            let first = history.front().unwrap();
12275            let last = history.back().unwrap();
12276
12277            let temp_diff = last.temperature as i32 - first.temperature as i32;
12278            let clock_diff = last.core_clock as i32 - first.core_clock as i32;
12279
12280            let temp_trend = if temp_diff > 1 {
12281                "Rising"
12282            } else if temp_diff < -1 {
12283                "Falling"
12284            } else {
12285                "Stable"
12286            };
12287            let clock_trend = if clock_diff > 10 {
12288                "Increasing"
12289            } else if clock_diff < -10 {
12290                "Decreasing"
12291            } else {
12292                "Stable"
12293            };
12294
12295            out.push_str(&format!(
12296                "- Temperature: {} ({}°C anomaly)\n",
12297                temp_trend, temp_diff
12298            ));
12299            out.push_str(&format!(
12300                "- Core Clock:  {} ({} MHz delta)\n",
12301                clock_trend, clock_diff
12302            ));
12303            out.push_str("\n");
12304        }
12305
12306        // 2. CPU Time-Series (2 samples)
12307        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))\" }";
12308        let cpu_stats = Command::new("powershell")
12309            .args(["-NoProfile", "-Command", ps_cmd])
12310            .output();
12311
12312        if let Ok(o) = cpu_stats {
12313            let stdout = String::from_utf8_lossy(&o.stdout);
12314            if !stdout.trim().is_empty() {
12315                out.push_str("=== SILICON CORE (CPU) ===\n");
12316                for line in stdout.lines() {
12317                    if let Some((path, val)) = line.split_once(':') {
12318                        if path.to_lowercase().contains("processor frequency") {
12319                            out.push_str(&format!("- Current Freq:  {} MHz (2s Avg)\n", val));
12320                        } else if path.to_lowercase().contains("% of maximum frequency") {
12321                            out.push_str(&format!("- Throttling:     {}% of Max Capacity\n", val));
12322                            let throttle_num = val.parse::<f64>().unwrap_or(100.0);
12323                            if throttle_num < 95.0 {
12324                                out.push_str(
12325                                    "  [WARNING] Active downclocking or power-saving detected.\n",
12326                                );
12327                            }
12328                        }
12329                    }
12330                }
12331            }
12332        }
12333
12334        // 2b. CPU Thermal Fallback
12335        let thermal = Command::new("powershell")
12336            .args([
12337                "-NoProfile",
12338                "-Command",
12339                "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
12340            ])
12341            .output();
12342        if let Ok(o) = thermal {
12343            let stdout = String::from_utf8_lossy(&o.stdout);
12344            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12345                let temp = if v.is_array() {
12346                    v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12347                } else {
12348                    v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
12349                };
12350                if temp > 1.0 {
12351                    out.push_str(&format!("- CPU Package:   {}°C (ACPI Zone)\n", temp));
12352                }
12353            }
12354        }
12355
12356        // 3. WMI Static Fallback/Context
12357        let wmi = Command::new("powershell")
12358            .args([
12359                "-NoProfile",
12360                "-Command",
12361                "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
12362            ])
12363            .output();
12364
12365        if let Ok(o) = wmi {
12366            let stdout = String::from_utf8_lossy(&o.stdout);
12367            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
12368                out.push_str("\n=== HARDWARE DNA ===\n");
12369                out.push_str(&format!(
12370                    "- Rated Max:     {} MHz\n",
12371                    v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
12372                ));
12373                match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
12374                    Some(raw) => {
12375                        if let Some(decoded) = decode_wmi_processor_voltage(raw) {
12376                            out.push_str(&format!("- CPU Voltage:   {}\n", decoded));
12377                        } else {
12378                            out.push_str(
12379                                "- CPU Voltage:   Unavailable or non-telemetry WMI value on this firmware path\n",
12380                            );
12381                        }
12382                    }
12383                    None => out.push_str("- CPU Voltage:   Unavailable on this WMI path\n"),
12384                }
12385            }
12386        }
12387    }
12388
12389    #[cfg(not(target_os = "windows"))]
12390    {
12391        out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
12392    }
12393
12394    Ok(out.trim_end().to_string())
12395}
12396
12397/// Decodes the NVIDIA Clocks Throttle Reasons HEX bitmask.
12398#[cfg(target_os = "windows")]
12399fn decode_nvidia_throttle_reasons(hex: &str) -> String {
12400    let hex = hex.trim().trim_start_matches("0x");
12401    let val = match u64::from_str_radix(hex, 16) {
12402        Ok(v) => v,
12403        Err(_) => return String::new(),
12404    };
12405
12406    if val == 0 {
12407        return String::new();
12408    }
12409
12410    let mut reasons = Vec::new();
12411    if val & 0x01 != 0 {
12412        reasons.push("GPU Idle");
12413    }
12414    if val & 0x02 != 0 {
12415        reasons.push("Applications Clocks Setting");
12416    }
12417    if val & 0x04 != 0 {
12418        reasons.push("SW Power Cap (PL1/PL2)");
12419    }
12420    if val & 0x08 != 0 {
12421        reasons.push("HW Slowdown (Thermal/Power)");
12422    }
12423    if val & 0x10 != 0 {
12424        reasons.push("Sync Boost");
12425    }
12426    if val & 0x20 != 0 {
12427        reasons.push("SW Thermal Slowdown");
12428    }
12429    if val & 0x40 != 0 {
12430        reasons.push("HW Thermal Slowdown");
12431    }
12432    if val & 0x80 != 0 {
12433        reasons.push("HW Power Brake Slowdown");
12434    }
12435    if val & 0x100 != 0 {
12436        reasons.push("Display Clock Setting");
12437    }
12438
12439    reasons.join(", ")
12440}
12441
12442// ── PowerShell helper (used by camera / sign_in / search_index) ───────────────
12443
12444#[cfg(windows)]
12445fn run_powershell(script: &str) -> Result<String, String> {
12446    use std::process::Command;
12447    let out = Command::new("powershell")
12448        .args(["-NoProfile", "-NonInteractive", "-Command", script])
12449        .output()
12450        .map_err(|e| format!("powershell launch failed: {e}"))?;
12451    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
12452}
12453
12454// ── inspect_camera ────────────────────────────────────────────────────────────
12455
12456#[cfg(windows)]
12457fn inspect_camera(max_entries: usize) -> Result<String, String> {
12458    let mut out = String::from("=== Camera devices ===\n");
12459
12460    // PnP camera devices
12461    let ps_devices = r#"
12462Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
12463ForEach-Object {
12464    $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
12465    "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
12466}
12467"#;
12468    match run_powershell(ps_devices) {
12469        Ok(o) if !o.trim().is_empty() => {
12470            for line in o.lines().take(max_entries) {
12471                let l = line.trim();
12472                if !l.is_empty() {
12473                    out.push_str(&format!("- {l}\n"));
12474                }
12475            }
12476        }
12477        _ => out.push_str("- No camera devices found via PnP\n"),
12478    }
12479
12480    // Windows privacy / capability gate
12481    out.push_str("\n=== Windows camera privacy ===\n");
12482    let ps_privacy = r#"
12483$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
12484$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
12485"Global: $global"
12486$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
12487    Where-Object { $_.PSChildName -ne 'NonPackaged' } |
12488    ForEach-Object {
12489        $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
12490        if ($v) { "  $($_.PSChildName): $v" }
12491    }
12492$apps
12493"#;
12494    match run_powershell(ps_privacy) {
12495        Ok(o) if !o.trim().is_empty() => {
12496            for line in o.lines().take(max_entries) {
12497                let l = line.trim_end();
12498                if !l.is_empty() {
12499                    out.push_str(&format!("{l}\n"));
12500                }
12501            }
12502        }
12503        _ => out.push_str("- Could not read camera privacy registry\n"),
12504    }
12505
12506    // Windows Hello camera (IR / face auth)
12507    out.push_str("\n=== Biometric / Hello camera ===\n");
12508    let ps_bio = r#"
12509Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
12510ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
12511"#;
12512    match run_powershell(ps_bio) {
12513        Ok(o) if !o.trim().is_empty() => {
12514            for line in o.lines().take(max_entries) {
12515                let l = line.trim();
12516                if !l.is_empty() {
12517                    out.push_str(&format!("- {l}\n"));
12518                }
12519            }
12520        }
12521        _ => out.push_str("- No biometric devices found\n"),
12522    }
12523
12524    // Findings
12525    let mut findings: Vec<String> = Vec::new();
12526    if out.contains("Status: Error") || out.contains("Status: Unknown") {
12527        findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
12528    }
12529    if out.contains("Global: Deny") {
12530        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());
12531    }
12532
12533    let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
12534    if findings.is_empty() {
12535        result.push_str("- No obvious camera or privacy gate issue detected.\n");
12536        result.push_str("  If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
12537    } else {
12538        for f in &findings {
12539            result.push_str(&format!("- Finding: {f}\n"));
12540        }
12541    }
12542    result.push('\n');
12543    result.push_str(&out);
12544    Ok(result)
12545}
12546
12547#[cfg(not(windows))]
12548fn inspect_camera(_max_entries: usize) -> Result<String, String> {
12549    Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
12550}
12551
12552// ── inspect_sign_in ───────────────────────────────────────────────────────────
12553
12554#[cfg(windows)]
12555fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
12556    let mut out = String::from("=== Windows Hello and sign-in state ===\n");
12557
12558    // Windows Hello PIN and face/fingerprint readiness
12559    let ps_hello = r#"
12560$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
12561$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
12562$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
12563"PIN-style logon path: $helloKey"
12564"WbioSrvc start type: $faceConfigured"
12565"FingerPrint key present: $pinConfigured"
12566"#;
12567    match run_powershell(ps_hello) {
12568        Ok(o) => {
12569            for line in o.lines().take(max_entries) {
12570                let l = line.trim();
12571                if !l.is_empty() {
12572                    out.push_str(&format!("- {l}\n"));
12573                }
12574            }
12575        }
12576        Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
12577    }
12578
12579    // Biometric service state
12580    out.push_str("\n=== Biometric service ===\n");
12581    let ps_bio_svc = r#"
12582$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
12583if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
12584else { "WbioSrvc not found" }
12585"#;
12586    match run_powershell(ps_bio_svc) {
12587        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
12588        Err(_) => out.push_str("- Could not query biometric service\n"),
12589    }
12590
12591    // Recent logon failure events
12592    out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
12593    let ps_events = r#"
12594$cutoff = (Get-Date).AddHours(-24)
12595Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
12596ForEach-Object {
12597    $xml = [xml]$_.ToXml()
12598    $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
12599    $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
12600    "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
12601} | Select-Object -First 10
12602"#;
12603    match run_powershell(ps_events) {
12604        Ok(o) if !o.trim().is_empty() => {
12605            let count = o.lines().filter(|l| !l.trim().is_empty()).count();
12606            out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
12607            for line in o.lines().take(max_entries) {
12608                let l = line.trim();
12609                if !l.is_empty() {
12610                    out.push_str(&format!("  {l}\n"));
12611                }
12612            }
12613        }
12614        _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
12615    }
12616
12617    // Credential providers
12618    out.push_str("\n=== Active credential providers ===\n");
12619    let ps_cp = r#"
12620Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
12621ForEach-Object {
12622    $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
12623    if ($name) { $name }
12624} | Select-Object -First 15
12625"#;
12626    match run_powershell(ps_cp) {
12627        Ok(o) if !o.trim().is_empty() => {
12628            for line in o.lines().take(max_entries) {
12629                let l = line.trim();
12630                if !l.is_empty() {
12631                    out.push_str(&format!("- {l}\n"));
12632                }
12633            }
12634        }
12635        _ => out.push_str("- Could not enumerate credential providers\n"),
12636    }
12637
12638    let mut findings: Vec<String> = Vec::new();
12639    if out.contains("WbioSrvc | Status: Stopped") {
12640        findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
12641    }
12642    if out.contains("recent logon failure") && !out.contains("0 recent") {
12643        findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
12644    }
12645
12646    let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
12647    if findings.is_empty() {
12648        result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
12649        result.push_str("  If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
12650    } else {
12651        for f in &findings {
12652            result.push_str(&format!("- Finding: {f}\n"));
12653        }
12654    }
12655    result.push('\n');
12656    result.push_str(&out);
12657    Ok(result)
12658}
12659
12660#[cfg(not(windows))]
12661fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
12662    Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
12663}
12664
12665// ── inspect_installer_health ──────────────────────────────────────────────────
12666
12667#[cfg(windows)]
12668fn inspect_installer_health(max_entries: usize) -> Result<String, String> {
12669    let mut out = String::from("=== Installer engines ===\n");
12670
12671    let ps_engines = r#"
12672$services = 'msiserver','AppXSvc','ClipSVC','InstallService'
12673foreach ($name in $services) {
12674    $svc = Get-Service -Name $name -ErrorAction SilentlyContinue
12675    if ($svc) {
12676        $cim = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
12677        $startType = if ($cim) { $cim.StartMode } else { 'Unknown' }
12678        "$name | Status: $($svc.Status) | StartType: $startType"
12679    } else {
12680        "$name | Not present"
12681    }
12682}
12683if (Test-Path "$env:WINDIR\System32\msiexec.exe") {
12684    "msiexec.exe | Present: Yes"
12685} else {
12686    "msiexec.exe | Present: No"
12687}
12688"#;
12689    match run_powershell(ps_engines) {
12690        Ok(o) if !o.trim().is_empty() => {
12691            for line in o.lines().take(max_entries + 6) {
12692                let l = line.trim();
12693                if !l.is_empty() {
12694                    out.push_str(&format!("- {l}\n"));
12695                }
12696            }
12697        }
12698        _ => out.push_str("- Could not inspect installer engine services\n"),
12699    }
12700
12701    out.push_str("\n=== winget and App Installer ===\n");
12702    let ps_winget = r#"
12703$cmd = Get-Command winget -ErrorAction SilentlyContinue
12704if ($cmd) {
12705    try {
12706        $v = & winget --version 2>$null
12707        if ($LASTEXITCODE -eq 0 -and $v) { "winget | Version: $v" } else { "winget | Present but version query failed" }
12708    } catch { "winget | Present but invocation failed" }
12709} else {
12710    "winget | Missing"
12711}
12712$appInstaller = Get-AppxPackage Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
12713if ($appInstaller) {
12714    "DesktopAppInstaller | Version: $($appInstaller.Version) | Status: Present"
12715} else {
12716    "DesktopAppInstaller | Status: Missing"
12717}
12718"#;
12719    match run_powershell(ps_winget) {
12720        Ok(o) if !o.trim().is_empty() => {
12721            for line in o.lines().take(max_entries) {
12722                let l = line.trim();
12723                if !l.is_empty() {
12724                    out.push_str(&format!("- {l}\n"));
12725                }
12726            }
12727        }
12728        _ => out.push_str("- Could not inspect winget/App Installer state\n"),
12729    }
12730
12731    out.push_str("\n=== Microsoft Store packages ===\n");
12732    let ps_store = r#"
12733$store = Get-AppxPackage Microsoft.WindowsStore -ErrorAction SilentlyContinue
12734if ($store) {
12735    "Microsoft.WindowsStore | Version: $($store.Version) | Status: Present"
12736} else {
12737    "Microsoft.WindowsStore | Status: Missing"
12738}
12739"#;
12740    match run_powershell(ps_store) {
12741        Ok(o) if !o.trim().is_empty() => {
12742            for line in o.lines().take(max_entries) {
12743                let l = line.trim();
12744                if !l.is_empty() {
12745                    out.push_str(&format!("- {l}\n"));
12746                }
12747            }
12748        }
12749        _ => out.push_str("- Could not inspect Microsoft Store package state\n"),
12750    }
12751
12752    out.push_str("\n=== Reboot and transaction blockers ===\n");
12753    let ps_blockers = r#"
12754$pending = $false
12755if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
12756    "RebootPending: CBS"
12757    $pending = $true
12758}
12759if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
12760    "RebootPending: WindowsUpdate"
12761    $pending = $true
12762}
12763$rename = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue).PendingFileRenameOperations
12764if ($rename) {
12765    "PendingFileRenameOperations: Yes"
12766    $pending = $true
12767}
12768if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Installer\InProgress') {
12769    "InstallerInProgress: Yes"
12770    $pending = $true
12771}
12772if (-not $pending) { "No pending reboot or installer-in-progress flag detected" }
12773"#;
12774    match run_powershell(ps_blockers) {
12775        Ok(o) if !o.trim().is_empty() => {
12776            for line in o.lines().take(max_entries) {
12777                let l = line.trim();
12778                if !l.is_empty() {
12779                    out.push_str(&format!("- {l}\n"));
12780                }
12781            }
12782        }
12783        _ => out.push_str("- Could not inspect reboot or transaction blockers\n"),
12784    }
12785
12786    out.push_str("\n=== Recent installer failures (7d) ===\n");
12787    let ps_failures = r#"
12788$cutoff = (Get-Date).AddDays(-7)
12789$msi = Get-WinEvent -FilterHashtable @{ LogName='Application'; ProviderName='MsiInstaller'; StartTime=$cutoff; Level=2 } -MaxEvents 6 -ErrorAction SilentlyContinue |
12790    ForEach-Object { "MSI | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
12791$appx = Get-WinEvent -LogName 'Microsoft-Windows-AppXDeploymentServer/Operational' -ErrorAction SilentlyContinue -MaxEvents 30 |
12792    Where-Object { $_.LevelDisplayName -eq 'Error' -and $_.TimeCreated -ge $cutoff } |
12793    Select-Object -First 6 |
12794    ForEach-Object { "AppX | $($_.TimeCreated.ToString('MM-dd HH:mm')) | EventId: $($_.Id) | $($_.Message -replace '\s+', ' ')" }
12795$all = @($msi) + @($appx)
12796if ($all.Count -eq 0) {
12797    "No recent MSI/AppX installer errors detected"
12798} else {
12799    $all | Select-Object -First 8
12800}
12801"#;
12802    match run_powershell(ps_failures) {
12803        Ok(o) if !o.trim().is_empty() => {
12804            for line in o.lines().take(max_entries + 2) {
12805                let l = line.trim();
12806                if !l.is_empty() {
12807                    out.push_str(&format!("- {l}\n"));
12808                }
12809            }
12810        }
12811        _ => out.push_str("- Could not inspect recent installer failure events\n"),
12812    }
12813
12814    let mut findings: Vec<String> = Vec::new();
12815    if out.contains("msiserver | Status: Stopped | StartType: Disabled") {
12816        findings.push("Windows Installer service (msiserver) is disabled - MSI installs cannot start until it is re-enabled.".into());
12817    }
12818    if out.contains("msiexec.exe | Present: No") {
12819        findings.push("msiexec.exe is missing from System32 - MSI installs will fail until Windows Installer is repaired.".into());
12820    }
12821    if out.contains("winget | Missing") {
12822        findings.push(
12823            "winget is missing - App Installer may not be installed or registered for this user."
12824                .into(),
12825        );
12826    }
12827    if out.contains("DesktopAppInstaller | Status: Missing") {
12828        findings.push("Microsoft Desktop App Installer is missing - winget and some app-installer flows will be unavailable.".into());
12829    }
12830    if out.contains("Microsoft.WindowsStore | Status: Missing") {
12831        findings.push(
12832            "Microsoft Store package is missing - Store-sourced installs and repairs may not work."
12833                .into(),
12834        );
12835    }
12836    if out.contains("RebootPending:") || out.contains("PendingFileRenameOperations: Yes") {
12837        findings.push("A pending reboot is present - installer transactions may stay blocked until the machine restarts.".into());
12838    }
12839    if out.contains("InstallerInProgress: Yes") {
12840        findings.push("Windows reports an installer transaction already in progress - concurrent installs may fail until it clears.".into());
12841    }
12842    if out.contains("MSI | ") || out.contains("AppX | ") {
12843        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());
12844    }
12845
12846    let mut result = String::from("Host inspection: installer_health\n\n=== Findings ===\n");
12847    if findings.is_empty() {
12848        result.push_str("- No obvious installer-platform blocker detected.\n");
12849    } else {
12850        for finding in &findings {
12851            result.push_str(&format!("- Finding: {finding}\n"));
12852        }
12853    }
12854    result.push('\n');
12855    result.push_str(&out);
12856    Ok(result)
12857}
12858
12859#[cfg(not(windows))]
12860fn inspect_installer_health(_max_entries: usize) -> Result<String, String> {
12861    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())
12862}
12863
12864// ── inspect_search_index ──────────────────────────────────────────────────────
12865
12866#[cfg(windows)]
12867fn inspect_onedrive(max_entries: usize) -> Result<String, String> {
12868    let mut out = String::from("=== OneDrive client ===\n");
12869
12870    let ps_client = r#"
12871$candidatePaths = @(
12872    (Join-Path $env:LOCALAPPDATA 'Microsoft\OneDrive\OneDrive.exe'),
12873    (Join-Path $env:ProgramFiles 'Microsoft OneDrive\OneDrive.exe'),
12874    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft OneDrive\OneDrive.exe')
12875) | Where-Object { $_ -and (Test-Path $_) }
12876$proc = Get-Process OneDrive -ErrorAction SilentlyContinue | Select-Object -First 1
12877$exe = $candidatePaths | Select-Object -First 1
12878if (-not $exe -and $proc) {
12879    try { $exe = $proc.Path } catch {}
12880}
12881if ($exe) {
12882    "Installed: Yes"
12883    "Executable: $exe"
12884    try { "Version: $((Get-Item $exe).VersionInfo.FileVersion)" } catch {}
12885} else {
12886    "Installed: Unknown"
12887}
12888if ($proc) {
12889    "Process: Running | PID: $($proc.Id)"
12890} else {
12891    "Process: Not running"
12892}
12893"#;
12894    match run_powershell(ps_client) {
12895        Ok(o) if !o.trim().is_empty() => {
12896            for line in o.lines().take(max_entries) {
12897                let l = line.trim();
12898                if !l.is_empty() {
12899                    out.push_str(&format!("- {l}\n"));
12900                }
12901            }
12902        }
12903        _ => out.push_str("- Could not inspect OneDrive client state\n"),
12904    }
12905
12906    out.push_str("\n=== OneDrive accounts ===\n");
12907    let ps_accounts = r#"
12908function MaskEmail([string]$Email) {
12909    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
12910    $parts = $Email.Split('@', 2)
12911    $local = $parts[0]
12912    $domain = $parts[1]
12913    if ($local.Length -le 1) { return "*@$domain" }
12914    return ($local.Substring(0,1) + "***@" + $domain)
12915}
12916$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
12917if (Test-Path $base) {
12918    Get-ChildItem $base -ErrorAction SilentlyContinue |
12919        Sort-Object PSChildName |
12920        Select-Object -First 12 |
12921        ForEach-Object {
12922            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
12923            $kind = if ($_.PSChildName -eq 'Personal') { 'Personal' } else { 'Business' }
12924            $mail = MaskEmail ([string]$p.UserEmail)
12925            $root = if ([string]::IsNullOrWhiteSpace([string]$p.UserFolder)) { 'Unknown' } else { [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder) }
12926            $exists = if ($root -eq 'Unknown') { 'Unknown' } elseif (Test-Path $root) { 'Yes' } else { 'No' }
12927            "$kind | Email: $mail | SyncRoot: $root | Exists: $exists"
12928        }
12929} else {
12930    "No OneDrive accounts configured"
12931}
12932"#;
12933    match run_powershell(ps_accounts) {
12934        Ok(o) if !o.trim().is_empty() => {
12935            for line in o.lines().take(max_entries) {
12936                let l = line.trim();
12937                if !l.is_empty() {
12938                    out.push_str(&format!("- {l}\n"));
12939                }
12940            }
12941        }
12942        _ => out.push_str("- Could not read OneDrive account registry state\n"),
12943    }
12944
12945    out.push_str("\n=== OneDrive policy overrides ===\n");
12946    let ps_policy = r#"
12947$paths = @(
12948    'HKLM:\SOFTWARE\Policies\Microsoft\OneDrive',
12949    'HKCU:\SOFTWARE\Policies\Microsoft\OneDrive'
12950)
12951$names = @(
12952    'DisableFileSyncNGSC',
12953    'DisableLibrariesDefaultSaveToOneDrive',
12954    'KFMSilentOptIn',
12955    'KFMBlockOptIn',
12956    'SilentAccountConfig'
12957)
12958$found = $false
12959foreach ($path in $paths) {
12960    if (Test-Path $path) {
12961        $p = Get-ItemProperty $path -ErrorAction SilentlyContinue
12962        foreach ($name in $names) {
12963            $value = $p.$name
12964            if ($null -ne $value -and [string]$value -ne '') {
12965                "$path | $name=$value"
12966                $found = $true
12967            }
12968        }
12969    }
12970}
12971if (-not $found) { "No OneDrive policy overrides detected" }
12972"#;
12973    match run_powershell(ps_policy) {
12974        Ok(o) if !o.trim().is_empty() => {
12975            for line in o.lines().take(max_entries) {
12976                let l = line.trim();
12977                if !l.is_empty() {
12978                    out.push_str(&format!("- {l}\n"));
12979                }
12980            }
12981        }
12982        _ => out.push_str("- Could not read OneDrive policy state\n"),
12983    }
12984
12985    out.push_str("\n=== Known Folder Backup ===\n");
12986    let ps_kfm = r#"
12987$base = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
12988$roots = @()
12989if (Test-Path $base) {
12990    Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {
12991        $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
12992        if ($p.UserFolder) {
12993            $roots += [Environment]::ExpandEnvironmentVariables([string]$p.UserFolder)
12994        }
12995    }
12996}
12997$roots = $roots | Select-Object -Unique
12998$shell = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders'
12999if (Test-Path $shell) {
13000    $props = Get-ItemProperty $shell -ErrorAction SilentlyContinue
13001    $folders = @(
13002        @{ Name='Desktop'; Value=$props.Desktop },
13003        @{ Name='Documents'; Value=$props.Personal },
13004        @{ Name='Pictures'; Value=$props.'My Pictures' }
13005    )
13006    foreach ($folder in $folders) {
13007        $path = [Environment]::ExpandEnvironmentVariables([string]$folder.Value)
13008        if ([string]::IsNullOrWhiteSpace($path)) { $path = 'Unknown' }
13009        $protected = $false
13010        foreach ($root in $roots) {
13011            if (-not [string]::IsNullOrWhiteSpace([string]$root) -and $path.ToLower().StartsWith($root.ToLower())) {
13012                $protected = $true
13013                break
13014            }
13015        }
13016        "$($folder.Name) | Path: $path | In OneDrive: $(if ($protected) { 'Yes' } else { 'No' })"
13017    }
13018} else {
13019    "Explorer shell folders unavailable"
13020}
13021"#;
13022    match run_powershell(ps_kfm) {
13023        Ok(o) if !o.trim().is_empty() => {
13024            for line in o.lines().take(max_entries) {
13025                let l = line.trim();
13026                if !l.is_empty() {
13027                    out.push_str(&format!("- {l}\n"));
13028                }
13029            }
13030        }
13031        _ => out.push_str("- Could not inspect Known Folder Backup state\n"),
13032    }
13033
13034    let mut findings: Vec<String> = Vec::new();
13035    if out.contains("Installed: Unknown") && !out.contains("Process: Running") {
13036        findings.push("OneDrive client installation could not be confirmed from standard paths in this session.".into());
13037    }
13038    if out.contains("No OneDrive accounts configured") {
13039        findings.push(
13040            "No OneDrive accounts are configured - sync cannot start until the user signs in."
13041                .into(),
13042        );
13043    }
13044    if out.contains("Process: Not running") && !out.contains("No OneDrive accounts configured") {
13045        findings.push("OneDrive accounts exist but the sync client is not running - sync may be paused until OneDrive starts.".into());
13046    }
13047    if out.contains("Exists: No") {
13048        findings.push("One or more configured OneDrive sync roots do not exist on disk - account linkage or folder redirection may be broken.".into());
13049    }
13050    if out.contains("DisableFileSyncNGSC=1") {
13051        findings
13052            .push("A OneDrive policy is disabling the sync client (DisableFileSyncNGSC=1).".into());
13053    }
13054    if out.contains("KFMBlockOptIn=1") {
13055        findings
13056            .push("A policy is blocking Known Folder Backup enrollment (KFMBlockOptIn=1).".into());
13057    }
13058    if out.contains("SyncRoot: C:\\") {
13059        let mut missing_kfm: Vec<&str> = Vec::new();
13060        for folder in ["Desktop", "Documents", "Pictures"] {
13061            if out.lines().any(|line| {
13062                line.contains(&format!("{folder} | Path:")) && line.contains("| In OneDrive: No")
13063            }) {
13064                missing_kfm.push(folder);
13065            }
13066        }
13067        if !missing_kfm.is_empty() {
13068            findings.push(format!(
13069                "Known Folder Backup is not protecting {} - those folders are outside the OneDrive sync root.",
13070                missing_kfm.join(", ")
13071            ));
13072        }
13073    }
13074
13075    let mut result = String::from("Host inspection: onedrive\n\n=== Findings ===\n");
13076    if findings.is_empty() {
13077        result.push_str("- No obvious OneDrive client, account, or policy blocker detected.\n");
13078    } else {
13079        for finding in &findings {
13080            result.push_str(&format!("- Finding: {finding}\n"));
13081        }
13082    }
13083    result.push('\n');
13084    result.push_str(&out);
13085    Ok(result)
13086}
13087
13088#[cfg(not(windows))]
13089fn inspect_onedrive(_max_entries: usize) -> Result<String, String> {
13090    Ok("Host inspection: onedrive\n\n=== Findings ===\n- OneDrive inspection is currently Windows-first. macOS/Linux support can be added later.\n".into())
13091}
13092
13093#[cfg(windows)]
13094fn inspect_browser_health(max_entries: usize) -> Result<String, String> {
13095    let mut out = String::from("=== Browser inventory ===\n");
13096
13097    let ps_inventory = r#"
13098$browsers = @(
13099    @{ Name='Edge'; Paths=@(
13100        (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'),
13101        (Join-Path $env:ProgramFiles 'Microsoft\Edge\Application\msedge.exe')
13102    ); Profile=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data') },
13103    @{ Name='Chrome'; Paths=@(
13104        (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
13105        (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
13106        (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
13107    ); Profile=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data') },
13108    @{ Name='Firefox'; Paths=@(
13109        (Join-Path $env:ProgramFiles 'Mozilla Firefox\firefox.exe'),
13110        (Join-Path ${env:ProgramFiles(x86)} 'Mozilla Firefox\firefox.exe')
13111    ); Profile=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles') }
13112)
13113foreach ($browser in $browsers) {
13114    $exe = $browser.Paths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13115    if ($exe) {
13116        $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13117        $profileExists = if (Test-Path $browser.Profile) { 'Yes' } else { 'No' }
13118        "$($browser.Name) | Installed: Yes | Version: $version | Executable: $exe | ProfileRoot: $($browser.Profile) | ProfileExists: $profileExists"
13119    } else {
13120        "$($browser.Name) | Installed: No"
13121    }
13122}
13123$httpProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13124$httpsProgId = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice' -Name ProgId -ErrorAction SilentlyContinue).ProgId
13125$startMenuInternet = (Get-ItemProperty 'HKLM:\SOFTWARE\Clients\StartMenuInternet' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
13126"DefaultHTTP: $(if ($httpProgId) { $httpProgId } else { 'Unknown' })"
13127"DefaultHTTPS: $(if ($httpsProgId) { $httpsProgId } else { 'Unknown' })"
13128"StartMenuInternet: $(if ($startMenuInternet) { $startMenuInternet } else { 'Unknown' })"
13129"#;
13130    match run_powershell(ps_inventory) {
13131        Ok(o) if !o.trim().is_empty() => {
13132            for line in o.lines().take(max_entries + 6) {
13133                let l = line.trim();
13134                if !l.is_empty() {
13135                    out.push_str(&format!("- {l}\n"));
13136                }
13137            }
13138        }
13139        _ => out.push_str("- Could not inspect installed browser inventory\n"),
13140    }
13141
13142    out.push_str("\n=== Runtime state ===\n");
13143    let ps_runtime = r#"
13144$targets = 'msedge','chrome','firefox','msedgewebview2'
13145foreach ($name in $targets) {
13146    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13147    if ($procs) {
13148        $count = @($procs).Count
13149        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13150        "$name | Processes: $count | WorkingSetMB: $wsMb"
13151    } else {
13152        "$name | Processes: 0 | WorkingSetMB: 0"
13153    }
13154}
13155"#;
13156    match run_powershell(ps_runtime) {
13157        Ok(o) if !o.trim().is_empty() => {
13158            for line in o.lines().take(max_entries + 4) {
13159                let l = line.trim();
13160                if !l.is_empty() {
13161                    out.push_str(&format!("- {l}\n"));
13162                }
13163            }
13164        }
13165        _ => out.push_str("- Could not inspect browser runtime state\n"),
13166    }
13167
13168    out.push_str("\n=== WebView2 runtime ===\n");
13169    let ps_webview = r#"
13170$paths = @(
13171    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13172    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13173) | Where-Object { $_ -and (Test-Path $_) }
13174$runtimeDir = $paths | ForEach-Object {
13175    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13176        Where-Object { $_.Name -match '^\d+\.' } |
13177        Sort-Object Name -Descending |
13178        Select-Object -First 1
13179} | Select-Object -First 1
13180if ($runtimeDir) {
13181    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
13182    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
13183    "Installed: Yes"
13184    "Version: $version"
13185    "Executable: $exe"
13186} else {
13187    "Installed: No"
13188}
13189$proc = Get-Process msedgewebview2 -ErrorAction SilentlyContinue
13190"ProcessCount: $(if ($proc) { @($proc).Count } else { 0 })"
13191"#;
13192    match run_powershell(ps_webview) {
13193        Ok(o) if !o.trim().is_empty() => {
13194            for line in o.lines().take(max_entries) {
13195                let l = line.trim();
13196                if !l.is_empty() {
13197                    out.push_str(&format!("- {l}\n"));
13198                }
13199            }
13200        }
13201        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
13202    }
13203
13204    out.push_str("\n=== Policy and proxy surface ===\n");
13205    let ps_policy = r#"
13206$proxy = Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
13207$proxyEnabled = if ($null -ne $proxy.ProxyEnable) { $proxy.ProxyEnable } else { 'Unknown' }
13208$proxyServer = if ($proxy.ProxyServer) { $proxy.ProxyServer } else { 'Direct' }
13209$autoConfig = if ($proxy.AutoConfigURL) { $proxy.AutoConfigURL } else { 'None' }
13210$autoDetect = if ($null -ne $proxy.AutoDetect) { $proxy.AutoDetect } else { 'Unknown' }
13211"UserProxyEnabled: $proxyEnabled"
13212"UserProxyServer: $proxyServer"
13213"UserAutoConfigURL: $autoConfig"
13214"UserAutoDetect: $autoDetect"
13215$winhttp = (netsh winhttp show proxy 2>$null) -join ' '
13216if ($winhttp) {
13217    $normalized = ($winhttp -replace '\s+', ' ').Trim()
13218    $isDirect = $normalized -match 'Direct access \(no proxy server\)\.?$'
13219    "WinHTTPMode: $(if ($isDirect) { 'Direct' } else { 'Proxy' })"
13220    "WinHTTP: $normalized"
13221}
13222$policyTargets = @(
13223    @{ Name='Edge'; Path='HKLM:\SOFTWARE\Policies\Microsoft\Edge'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') },
13224    @{ Name='Chrome'; Path='HKLM:\SOFTWARE\Policies\Google\Chrome'; Keys=@('ProxyMode','ProxyServer','ProxyPacUrl','ExtensionInstallForcelist') }
13225)
13226foreach ($policy in $policyTargets) {
13227    if (Test-Path $policy.Path) {
13228        $item = Get-ItemProperty $policy.Path -ErrorAction SilentlyContinue
13229        foreach ($key in $policy.Keys) {
13230            $value = $item.$key
13231            if ($null -ne $value -and [string]$value -ne '') {
13232                if ($value -is [array]) {
13233                    "$($policy.Name)Policy | $key=$([string]::Join('; ', $value))"
13234                } else {
13235                    "$($policy.Name)Policy | $key=$value"
13236                }
13237            }
13238        }
13239    }
13240}
13241"#;
13242    match run_powershell(ps_policy) {
13243        Ok(o) if !o.trim().is_empty() => {
13244            for line in o.lines().take(max_entries + 8) {
13245                let l = line.trim();
13246                if !l.is_empty() {
13247                    out.push_str(&format!("- {l}\n"));
13248                }
13249            }
13250        }
13251        _ => out.push_str("- Could not inspect browser policy or proxy state\n"),
13252    }
13253
13254    out.push_str("\n=== Profile and cache pressure ===\n");
13255    let ps_profiles = r#"
13256$profiles = @(
13257    @{ Name='Edge'; Root=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Microsoft\Edge\User Data\Default\Extensions') },
13258    @{ Name='Chrome'; Root=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data'); ExtensionRoot=(Join-Path $env:LOCALAPPDATA 'Google\Chrome\User Data\Default\Extensions') },
13259    @{ Name='Firefox'; Root=(Join-Path $env:APPDATA 'Mozilla\Firefox\Profiles'); ExtensionRoot=$null }
13260)
13261foreach ($profile in $profiles) {
13262    if (Test-Path $profile.Root) {
13263        if ($profile.Name -eq 'Firefox') {
13264            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue
13265        } else {
13266            $dirs = Get-ChildItem $profile.Root -Directory -ErrorAction SilentlyContinue |
13267                Where-Object {
13268                    $_.Name -eq 'Default' -or
13269                    $_.Name -eq 'Guest Profile' -or
13270                    $_.Name -eq 'System Profile' -or
13271                    $_.Name -like 'Profile *'
13272                }
13273        }
13274        $profileCount = @($dirs).Count
13275        $sizeBytes = (Get-ChildItem $profile.Root -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum
13276        if (-not $sizeBytes) { $sizeBytes = 0 }
13277        $sizeGb = [Math]::Round(($sizeBytes / 1GB), 2)
13278        $extCount = 'Unknown'
13279        if ($profile.ExtensionRoot -and (Test-Path $profile.ExtensionRoot)) {
13280            $extCount = @((Get-ChildItem $profile.ExtensionRoot -Directory -ErrorAction SilentlyContinue)).Count
13281        }
13282        "$($profile.Name) | ProfileRoot: $($profile.Root) | Profiles: $profileCount | SizeGB: $sizeGb | Extensions: $extCount"
13283    } else {
13284        "$($profile.Name) | ProfileRoot: Missing"
13285    }
13286}
13287"#;
13288    match run_powershell(ps_profiles) {
13289        Ok(o) if !o.trim().is_empty() => {
13290            for line in o.lines().take(max_entries + 4) {
13291                let l = line.trim();
13292                if !l.is_empty() {
13293                    out.push_str(&format!("- {l}\n"));
13294                }
13295            }
13296        }
13297        _ => out.push_str("- Could not inspect browser profile pressure\n"),
13298    }
13299
13300    out.push_str("\n=== Recent browser failures (7d) ===\n");
13301    let ps_failures = r#"
13302$cutoff = (Get-Date).AddDays(-7)
13303$targets = 'chrome.exe','msedge.exe','firefox.exe','msedgewebview2.exe'
13304$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 250 -ErrorAction SilentlyContinue |
13305    Where-Object {
13306        $msg = [string]$_.Message
13307        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
13308        ($targets | Where-Object { $msg.ToLower().Contains($_.ToLower()) })
13309    } |
13310    Select-Object -First 6
13311if ($events) {
13312    foreach ($event in $events) {
13313        $msg = ($event.Message -replace '\s+', ' ')
13314        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
13315        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
13316    }
13317} else {
13318    "No recent browser crash or WER events detected"
13319}
13320"#;
13321    match run_powershell(ps_failures) {
13322        Ok(o) if !o.trim().is_empty() => {
13323            for line in o.lines().take(max_entries + 2) {
13324                let l = line.trim();
13325                if !l.is_empty() {
13326                    out.push_str(&format!("- {l}\n"));
13327                }
13328            }
13329        }
13330        _ => out.push_str("- Could not inspect recent browser failure events\n"),
13331    }
13332
13333    let mut findings: Vec<String> = Vec::new();
13334    if out.contains("Edge | Installed: No")
13335        && out.contains("Chrome | Installed: No")
13336        && out.contains("Firefox | Installed: No")
13337    {
13338        findings.push(
13339            "No supported browser install was detected from the standard Edge/Chrome/Firefox paths."
13340                .into(),
13341        );
13342    }
13343    if out.contains("DefaultHTTP: Unknown") || out.contains("DefaultHTTPS: Unknown") {
13344        findings.push(
13345            "Default browser or protocol associations could not be read cleanly - links may open inconsistently."
13346                .into(),
13347        );
13348    }
13349    if out.contains("UserProxyEnabled: 1") || out.contains("WinHTTPMode: Proxy") {
13350        findings.push(
13351            "Proxy settings are active for this user or machine - browser sign-in and web-app failures may be proxy or PAC related."
13352                .into(),
13353        );
13354    }
13355    if out.contains("EdgePolicy | Proxy")
13356        || out.contains("ChromePolicy | Proxy")
13357        || out.contains("ExtensionInstallForcelist=")
13358    {
13359        findings.push(
13360            "Browser policy overrides are present - forced proxy or extension policy may be influencing web-app behavior."
13361                .into(),
13362        );
13363    }
13364    for browser in ["msedge", "chrome", "firefox"] {
13365        let process_marker = format!("{browser} | Processes: ");
13366        if let Some(line) = out.lines().find(|line| line.contains(&process_marker)) {
13367            let count = line
13368                .split("| Processes: ")
13369                .nth(1)
13370                .and_then(|rest| rest.split(" |").next())
13371                .and_then(|value| value.trim().parse::<usize>().ok())
13372                .unwrap_or(0);
13373            let ws_mb = line
13374                .split("| WorkingSetMB: ")
13375                .nth(1)
13376                .and_then(|value| value.trim().parse::<f64>().ok())
13377                .unwrap_or(0.0);
13378            if count >= 25 {
13379                findings.push(format!(
13380                    "{browser} is running {count} processes - extension or tab pressure may be dragging browser responsiveness."
13381                ));
13382            } else if ws_mb >= 2500.0 {
13383                findings.push(format!(
13384                    "{browser} is consuming {ws_mb:.1} MB of working set - browser memory pressure may be driving slowness or tab crashes."
13385                ));
13386            }
13387        }
13388    }
13389    if out.contains("=== WebView2 runtime ===\n- Installed: No")
13390        || (out.contains("=== WebView2 runtime ===")
13391            && out.contains("- Installed: No")
13392            && out.contains("- ProcessCount: 0"))
13393    {
13394        findings.push(
13395            "WebView2 runtime is missing - modern Windows apps that embed Edge web content may fail or render badly."
13396                .into(),
13397        );
13398    }
13399    for browser in ["Edge", "Chrome", "Firefox"] {
13400        let prefix = format!("{browser} | ProfileRoot:");
13401        if let Some(line) = out.lines().find(|line| line.contains(&prefix)) {
13402            let size_gb = line
13403                .split("| SizeGB: ")
13404                .nth(1)
13405                .and_then(|rest| rest.split(" |").next())
13406                .and_then(|value| value.trim().parse::<f64>().ok())
13407                .unwrap_or(0.0);
13408            let ext_count = line
13409                .split("| Extensions: ")
13410                .nth(1)
13411                .and_then(|value| value.trim().parse::<usize>().ok())
13412                .unwrap_or(0);
13413            if size_gb >= 2.5 {
13414                findings.push(format!(
13415                    "{browser} profile data is {size_gb:.2} GB - cache or profile bloat may be hurting startup and web-app responsiveness."
13416                ));
13417            }
13418            if ext_count >= 20 {
13419                findings.push(format!(
13420                    "{browser} has {ext_count} extensions in the default profile - extension overload can slow page loads and trigger conflicts."
13421                ));
13422            }
13423        }
13424    }
13425    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
13426        findings.push(
13427            "Recent browser crash evidence was found in the Application log - review the failure lines below for the browser or helper process that is faulting."
13428                .into(),
13429        );
13430    }
13431
13432    let mut result = String::from("Host inspection: browser_health\n\n=== Findings ===\n");
13433    if findings.is_empty() {
13434        result.push_str("- No obvious browser, proxy, or WebView2 health blocker detected.\n");
13435    } else {
13436        for finding in &findings {
13437            result.push_str(&format!("- Finding: {finding}\n"));
13438        }
13439    }
13440    result.push('\n');
13441    result.push_str(&out);
13442    Ok(result)
13443}
13444
13445#[cfg(not(windows))]
13446fn inspect_browser_health(_max_entries: usize) -> Result<String, String> {
13447    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())
13448}
13449
13450#[cfg(windows)]
13451fn inspect_outlook(max_entries: usize) -> Result<String, String> {
13452    let mut out = String::from("=== Outlook install inventory ===\n");
13453
13454    let ps_install = r#"
13455$installPaths = @(
13456    (Join-Path $env:ProgramFiles 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
13457    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\root\Office16\OUTLOOK.EXE'),
13458    (Join-Path $env:ProgramFiles 'Microsoft Office\Office16\OUTLOOK.EXE'),
13459    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office16\OUTLOOK.EXE'),
13460    (Join-Path $env:ProgramFiles 'Microsoft Office\Office15\OUTLOOK.EXE'),
13461    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Office\Office15\OUTLOOK.EXE')
13462)
13463$exe = $installPaths | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13464if ($exe) {
13465    $version = try { (Get-Item $exe).VersionInfo.FileVersion } catch { 'Unknown' }
13466    $productName = try { (Get-Item $exe).VersionInfo.ProductName } catch { 'Unknown' }
13467    "Installed: Yes"
13468    "Executable: $exe"
13469    "Version: $version"
13470    "Product: $productName"
13471} else {
13472    "Installed: No"
13473}
13474$newOutlook = Get-AppxPackage -Name 'Microsoft.OutlookForWindows' -ErrorAction SilentlyContinue
13475if ($newOutlook) {
13476    "NewOutlook: Installed | Version: $($newOutlook.Version)"
13477} else {
13478    "NewOutlook: Not installed"
13479}
13480"#;
13481    match run_powershell(ps_install) {
13482        Ok(o) if !o.trim().is_empty() => {
13483            for line in o.lines().take(max_entries + 4) {
13484                let l = line.trim();
13485                if !l.is_empty() {
13486                    out.push_str(&format!("- {l}\n"));
13487                }
13488            }
13489        }
13490        _ => out.push_str("- Could not inspect Outlook install paths\n"),
13491    }
13492
13493    out.push_str("\n=== Runtime state ===\n");
13494    let ps_runtime = r#"
13495$proc = Get-Process OUTLOOK -ErrorAction SilentlyContinue
13496if ($proc) {
13497    $count = @($proc).Count
13498    $wsMb = [Math]::Round((($proc | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13499    $cpuPct = try { [Math]::Round(($proc | Measure-Object CPU -Sum).Sum, 1) } catch { 0 }
13500    "Running: Yes | ProcessCount: $count | WorkingSetMB: $wsMb | CPUSeconds: $cpuPct"
13501} else {
13502    "Running: No"
13503}
13504"#;
13505    match run_powershell(ps_runtime) {
13506        Ok(o) if !o.trim().is_empty() => {
13507            for line in o.lines().take(4) {
13508                let l = line.trim();
13509                if !l.is_empty() {
13510                    out.push_str(&format!("- {l}\n"));
13511                }
13512            }
13513        }
13514        _ => out.push_str("- Could not inspect Outlook runtime state\n"),
13515    }
13516
13517    out.push_str("\n=== Mail profiles ===\n");
13518    let ps_profiles = r#"
13519$profileKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles'
13520if (-not (Test-Path $profileKey)) {
13521    $profileKey = 'HKCU:\Software\Microsoft\Office\15.0\Outlook\Profiles'
13522}
13523if (Test-Path $profileKey) {
13524    $profiles = Get-ChildItem $profileKey -ErrorAction SilentlyContinue
13525    $count = @($profiles).Count
13526    "ProfileCount: $count"
13527    foreach ($p in $profiles | Select-Object -First 10) {
13528        "Profile: $($p.PSChildName)"
13529    }
13530} else {
13531    "ProfileCount: 0"
13532    "No Outlook profiles found in registry"
13533}
13534"#;
13535    match run_powershell(ps_profiles) {
13536        Ok(o) if !o.trim().is_empty() => {
13537            for line in o.lines().take(max_entries + 2) {
13538                let l = line.trim();
13539                if !l.is_empty() {
13540                    out.push_str(&format!("- {l}\n"));
13541                }
13542            }
13543        }
13544        _ => out.push_str("- Could not inspect Outlook mail profiles\n"),
13545    }
13546
13547    out.push_str("\n=== OST and PST data files ===\n");
13548    let ps_datafiles = r#"
13549$searchRoots = @(
13550    (Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook'),
13551    (Join-Path $env:USERPROFILE 'Documents'),
13552    (Join-Path $env:USERPROFILE 'OneDrive\Documents')
13553) | Where-Object { $_ -and (Test-Path $_) }
13554$files = foreach ($root in $searchRoots) {
13555    Get-ChildItem $root -Include '*.ost','*.pst' -Recurse -ErrorAction SilentlyContinue -Force |
13556        Select-Object FullName,
13557            @{N='SizeMB';E={[Math]::Round($_.Length/1MB,1)}},
13558            @{N='Type';E={$_.Extension.TrimStart('.').ToUpper()}},
13559            LastWriteTime
13560}
13561if ($files) {
13562    foreach ($f in ($files | Sort-Object SizeMB -Descending | Select-Object -First 12)) {
13563        "$($f.Type) | $($f.FullName) | SizeMB: $($f.SizeMB) | LastWrite: $($f.LastWriteTime.ToString('yyyy-MM-dd'))"
13564    }
13565} else {
13566    "No OST or PST files found in standard locations"
13567}
13568"#;
13569    match run_powershell(ps_datafiles) {
13570        Ok(o) if !o.trim().is_empty() => {
13571            for line in o.lines().take(max_entries + 4) {
13572                let l = line.trim();
13573                if !l.is_empty() {
13574                    out.push_str(&format!("- {l}\n"));
13575                }
13576            }
13577        }
13578        _ => out.push_str("- Could not inspect OST/PST data files\n"),
13579    }
13580
13581    out.push_str("\n=== Add-in pressure ===\n");
13582    let ps_addins = r#"
13583$addinPaths = @(
13584    'HKLM:\SOFTWARE\Microsoft\Office\Outlook\Addins',
13585    'HKCU:\SOFTWARE\Microsoft\Office\Outlook\Addins',
13586    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Office\Outlook\Addins'
13587)
13588$addins = foreach ($path in $addinPaths) {
13589    if (Test-Path $path) {
13590        Get-ChildItem $path -ErrorAction SilentlyContinue | ForEach-Object {
13591            $item = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
13592            $loadBehavior = $item.LoadBehavior
13593            $desc = if ($item.Description) { $item.Description } else { $_.PSChildName }
13594            [PSCustomObject]@{ Name=$desc; LoadBehavior=$loadBehavior; Key=$_.PSChildName }
13595        }
13596    }
13597}
13598$enabledCount = ($addins | Where-Object { $_.LoadBehavior -band 1 }).Count
13599$disabledCount = ($addins | Where-Object { $_.LoadBehavior -eq 0 }).Count
13600"TotalAddins: $(@($addins).Count) | Active: $enabledCount | Disabled: $disabledCount"
13601foreach ($a in ($addins | Sort-Object LoadBehavior -Descending | Select-Object -First 15)) {
13602    $state = switch ($a.LoadBehavior) {
13603        0 { 'Disabled' }
13604        2 { 'LoadOnStart(inactive)' }
13605        3 { 'ActiveOnStart' }
13606        8 { 'DemandLoad' }
13607        9 { 'ActiveDemand' }
13608        16 { 'ConnectedFirst' }
13609        default { "LoadBehavior=$($a.LoadBehavior)" }
13610    }
13611    "$($a.Name) | $state"
13612}
13613$crashedKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DoNotDisableAddinList'
13614$disabledByResiliency = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Resiliency\DisabledItems'
13615if (Test-Path $disabledByResiliency) {
13616    $dis = Get-ItemProperty $disabledByResiliency -ErrorAction SilentlyContinue
13617    $count = ($dis.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' }).Count
13618    if ($count -gt 0) { "ResiliencyDisabledItems: $count (add-ins crashed and were auto-disabled)" }
13619}
13620"#;
13621    match run_powershell(ps_addins) {
13622        Ok(o) if !o.trim().is_empty() => {
13623            for line in o.lines().take(max_entries + 8) {
13624                let l = line.trim();
13625                if !l.is_empty() {
13626                    out.push_str(&format!("- {l}\n"));
13627                }
13628            }
13629        }
13630        _ => out.push_str("- Could not inspect Outlook add-ins\n"),
13631    }
13632
13633    out.push_str("\n=== Authentication and cache friction ===\n");
13634    let ps_auth = r#"
13635$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
13636$tokenCount = if (Test-Path $tokenCache) {
13637    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
13638} else { 0 }
13639"TokenBrokerCacheFiles: $tokenCount"
13640$credentialManager = cmdkey /list 2>&1 | Select-String 'MicrosoftOffice|ADALCache|microsoftoffice|MsoOpenIdConnect'
13641$credsCount = @($credentialManager).Count
13642"OfficeCredentialsInVault: $credsCount"
13643$samlKey = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
13644if (Test-Path $samlKey) {
13645    $id = Get-ItemProperty $samlKey -ErrorAction SilentlyContinue
13646    $connected = if ($id.ConnectedAccountWamOverride) { $id.ConnectedAccountWamOverride } else { 'Unknown' }
13647    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
13648    "WAMOverride: $connected"
13649    "SignedInUserId: $signedIn"
13650}
13651$outlookReg = 'HKCU:\Software\Microsoft\Office\16.0\Outlook'
13652if (Test-Path $outlookReg) {
13653    $olk = Get-ItemProperty $outlookReg -ErrorAction SilentlyContinue
13654    if ($olk.DisableMAPI) { "DisableMAPI: $($olk.DisableMAPI)" }
13655}
13656"#;
13657    match run_powershell(ps_auth) {
13658        Ok(o) if !o.trim().is_empty() => {
13659            for line in o.lines().take(max_entries + 4) {
13660                let l = line.trim();
13661                if !l.is_empty() {
13662                    out.push_str(&format!("- {l}\n"));
13663                }
13664            }
13665        }
13666        _ => out.push_str("- Could not inspect Outlook auth state\n"),
13667    }
13668
13669    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
13670    let ps_events = r#"
13671$cutoff = (Get-Date).AddDays(-7)
13672$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
13673    Where-Object {
13674        $msg = [string]$_.Message
13675        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting' -or $_.ProviderName -eq 'Outlook') -and
13676        ($msg.ToLower().Contains('outlook') -or $msg.ToLower().Contains('mso.dll') -or $msg.ToLower().Contains('outllib.dll') -or $msg.ToLower().Contains('olmapi32.dll'))
13677    } |
13678    Select-Object -First 8
13679if ($events) {
13680    foreach ($event in $events) {
13681        $msg = ($event.Message -replace '\s+', ' ')
13682        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
13683        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
13684    }
13685} else {
13686    "No recent Outlook crash or error events detected in Application log"
13687}
13688"#;
13689    match run_powershell(ps_events) {
13690        Ok(o) if !o.trim().is_empty() => {
13691            for line in o.lines().take(max_entries + 4) {
13692                let l = line.trim();
13693                if !l.is_empty() {
13694                    out.push_str(&format!("- {l}\n"));
13695                }
13696            }
13697        }
13698        _ => out.push_str("- Could not inspect Outlook event log evidence\n"),
13699    }
13700
13701    let mut findings: Vec<String> = Vec::new();
13702
13703    if out.contains("- Installed: No") && out.contains("- NewOutlook: Not installed") {
13704        findings.push(
13705            "Outlook is not installed — neither classic Office nor the new Outlook for Windows was found."
13706                .into(),
13707        );
13708    }
13709
13710    if let Some(line) = out.lines().find(|l| l.contains("WorkingSetMB:")) {
13711        let ws_mb = line
13712            .split("WorkingSetMB: ")
13713            .nth(1)
13714            .and_then(|r| r.split(" |").next())
13715            .and_then(|v| v.trim().parse::<f64>().ok())
13716            .unwrap_or(0.0);
13717        if ws_mb >= 1500.0 {
13718            findings.push(format!(
13719                "Outlook is consuming {ws_mb:.0} MB of RAM — add-in pressure, large OST files, or a corrupt profile may be driving memory growth."
13720            ));
13721        }
13722    }
13723
13724    let large_ost: Vec<String> = out
13725        .lines()
13726        .filter(|l| l.contains("SizeMB:") && l.contains("OST"))
13727        .filter_map(|l| {
13728            let mb = l
13729                .split("SizeMB: ")
13730                .nth(1)
13731                .and_then(|r| r.split(" |").next())
13732                .and_then(|v| v.trim().parse::<f64>().ok())
13733                .unwrap_or(0.0);
13734            if mb >= 10_000.0 {
13735                Some(format!("{mb:.0} MB OST file detected"))
13736            } else {
13737                None
13738            }
13739        })
13740        .collect();
13741    for msg in large_ost {
13742        findings.push(format!(
13743            "{msg} — large OST files can cause Outlook slowness, send/receive delays, and search index rebuild time."
13744        ));
13745    }
13746
13747    if let Some(line) = out.lines().find(|l| l.contains("TotalAddins:")) {
13748        let active_count = line
13749            .split("Active: ")
13750            .nth(1)
13751            .and_then(|r| r.split(" |").next())
13752            .and_then(|v| v.trim().parse::<usize>().ok())
13753            .unwrap_or(0);
13754        if active_count >= 8 {
13755            findings.push(format!(
13756                "{active_count} active Outlook add-ins detected — add-in overload is a common cause of slow Outlook startup, freezes, and crashes."
13757            ));
13758        }
13759    }
13760
13761    if out.contains("ResiliencyDisabledItems:") {
13762        findings.push(
13763            "Outlook's crash resiliency has auto-disabled one or more add-ins — look at the ResiliencyDisabledItems count and remove the offending add-in."
13764                .into(),
13765        );
13766    }
13767
13768    if out.contains("- ProfileCount: 0") || out.contains("- No Outlook profiles found") {
13769        findings.push(
13770            "No Outlook mail profiles were found in the registry — Outlook may not have been set up, or the profile may be corrupt."
13771                .into(),
13772        );
13773    }
13774
13775    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
13776        findings.push(
13777            "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)."
13778                .into(),
13779        );
13780    }
13781
13782    let mut result = String::from("Host inspection: outlook\n\n=== Findings ===\n");
13783    if findings.is_empty() {
13784        result.push_str("- No obvious Outlook health blocker detected.\n");
13785    } else {
13786        for finding in &findings {
13787            result.push_str(&format!("- Finding: {finding}\n"));
13788        }
13789    }
13790    result.push('\n');
13791    result.push_str(&out);
13792    Ok(result)
13793}
13794
13795#[cfg(not(windows))]
13796fn inspect_outlook(_max_entries: usize) -> Result<String, String> {
13797    Ok("Host inspection: outlook\n\n=== Findings ===\n- Outlook health inspection is Windows-only.\n".into())
13798}
13799
13800#[cfg(windows)]
13801fn inspect_teams(max_entries: usize) -> Result<String, String> {
13802    let mut out = String::from("=== Teams install inventory ===\n");
13803
13804    let ps_install = r#"
13805# Classic Teams (Teams 1.0)
13806$classicExe = @(
13807    (Join-Path $env:LOCALAPPDATA 'Microsoft\Teams\current\Teams.exe'),
13808    (Join-Path $env:ProgramFiles 'Microsoft\Teams\current\Teams.exe')
13809) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13810
13811if ($classicExe) {
13812    $ver = try { (Get-Item $classicExe).VersionInfo.FileVersion } catch { 'Unknown' }
13813    "ClassicTeams: Installed | Version: $ver | Path: $classicExe"
13814} else {
13815    "ClassicTeams: Not installed"
13816}
13817
13818# New Teams (Teams 2.0 / ms-teams.exe)
13819$newTeamsExe = @(
13820    (Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\ms-teams.exe'),
13821    (Join-Path $env:ProgramFiles 'WindowsApps\MSTeams_*\ms-teams.exe')
13822) | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
13823
13824$newTeamsPkg = Get-AppxPackage -Name 'MSTeams' -ErrorAction SilentlyContinue
13825if ($newTeamsPkg) {
13826    "NewTeams: Installed | Version: $($newTeamsPkg.Version) | PackageName: $($newTeamsPkg.PackageFullName)"
13827} elseif ($newTeamsExe) {
13828    $ver = try { (Get-Item $newTeamsExe).VersionInfo.FileVersion } catch { 'Unknown' }
13829    "NewTeams: Installed | Version: $ver | Path: $newTeamsExe"
13830} else {
13831    "NewTeams: Not installed"
13832}
13833
13834# Teams Machine-Wide Installer (MSI/per-machine)
13835$mwi = Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue |
13836    Where-Object { $_.DisplayName -like 'Teams Machine-Wide Installer*' } |
13837    Select-Object -First 1
13838if ($mwi) {
13839    "MachineWideInstaller: Installed | Version: $($mwi.DisplayVersion)"
13840} else {
13841    "MachineWideInstaller: Not found"
13842}
13843"#;
13844    match run_powershell(ps_install) {
13845        Ok(o) if !o.trim().is_empty() => {
13846            for line in o.lines().take(max_entries + 4) {
13847                let l = line.trim();
13848                if !l.is_empty() {
13849                    out.push_str(&format!("- {l}\n"));
13850                }
13851            }
13852        }
13853        _ => out.push_str("- Could not inspect Teams install paths\n"),
13854    }
13855
13856    out.push_str("\n=== Runtime state ===\n");
13857    let ps_runtime = r#"
13858$targets = @('Teams','ms-teams')
13859foreach ($name in $targets) {
13860    $procs = Get-Process -Name $name -ErrorAction SilentlyContinue
13861    if ($procs) {
13862        $count = @($procs).Count
13863        $wsMb = [Math]::Round((($procs | Measure-Object WorkingSet64 -Sum).Sum / 1MB), 1)
13864        "$name | Running: Yes | Processes: $count | WorkingSetMB: $wsMb"
13865    } else {
13866        "$name | Running: No"
13867    }
13868}
13869"#;
13870    match run_powershell(ps_runtime) {
13871        Ok(o) if !o.trim().is_empty() => {
13872            for line in o.lines().take(6) {
13873                let l = line.trim();
13874                if !l.is_empty() {
13875                    out.push_str(&format!("- {l}\n"));
13876                }
13877            }
13878        }
13879        _ => out.push_str("- Could not inspect Teams runtime state\n"),
13880    }
13881
13882    out.push_str("\n=== Cache directory sizing ===\n");
13883    let ps_cache = r#"
13884$cachePaths = @(
13885    @{ Name='ClassicTeamsCache'; Path=(Join-Path $env:APPDATA 'Microsoft\Teams') },
13886    @{ Name='ClassicTeamsSquirrel'; Path=(Join-Path $env:LOCALAPPDATA 'Microsoft\Teams') },
13887    @{ Name='NewTeamsCache'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams') },
13888    @{ Name='NewTeamsAppData'; Path=(Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe') }
13889)
13890foreach ($entry in $cachePaths) {
13891    if (Test-Path $entry.Path) {
13892        $sizeBytes = (Get-ChildItem $entry.Path -Recurse -File -ErrorAction SilentlyContinue -Force | Measure-Object Length -Sum).Sum
13893        if (-not $sizeBytes) { $sizeBytes = 0 }
13894        $sizeMb = [Math]::Round($sizeBytes / 1MB, 1)
13895        "$($entry.Name) | Path: $($entry.Path) | SizeMB: $sizeMb"
13896    } else {
13897        "$($entry.Name) | Path: $($entry.Path) | Not found"
13898    }
13899}
13900"#;
13901    match run_powershell(ps_cache) {
13902        Ok(o) if !o.trim().is_empty() => {
13903            for line in o.lines().take(max_entries + 4) {
13904                let l = line.trim();
13905                if !l.is_empty() {
13906                    out.push_str(&format!("- {l}\n"));
13907                }
13908            }
13909        }
13910        _ => out.push_str("- Could not inspect Teams cache directories\n"),
13911    }
13912
13913    out.push_str("\n=== WebView2 runtime ===\n");
13914    let ps_webview = r#"
13915$paths = @(
13916    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
13917    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
13918) | Where-Object { $_ -and (Test-Path $_) }
13919$runtimeDir = $paths | ForEach-Object {
13920    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
13921        Where-Object { $_.Name -match '^\d+\.' } |
13922        Sort-Object Name -Descending |
13923        Select-Object -First 1
13924} | Select-Object -First 1
13925if ($runtimeDir) {
13926    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
13927    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
13928    "Installed: Yes | Version: $version"
13929} else {
13930    "Installed: No -- New Teams and some Office features require WebView2"
13931}
13932"#;
13933    match run_powershell(ps_webview) {
13934        Ok(o) if !o.trim().is_empty() => {
13935            for line in o.lines().take(4) {
13936                let l = line.trim();
13937                if !l.is_empty() {
13938                    out.push_str(&format!("- {l}\n"));
13939                }
13940            }
13941        }
13942        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
13943    }
13944
13945    out.push_str("\n=== Account and sign-in state ===\n");
13946    let ps_auth = r#"
13947# Classic Teams account registry
13948$classicAcct = 'HKCU:\Software\Microsoft\Office\Teams'
13949if (Test-Path $classicAcct) {
13950    $item = Get-ItemProperty $classicAcct -ErrorAction SilentlyContinue
13951    $email = if ($item.HomeUserUpn) { $item.HomeUserUpn } elseif ($item.LoggedInEmail) { $item.LoggedInEmail } else { 'Unknown' }
13952    "ClassicTeamsAccount: $email"
13953} else {
13954    "ClassicTeamsAccount: Not configured"
13955}
13956# WAM / token broker state for Teams
13957$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
13958$tokenCount = if (Test-Path $tokenCache) {
13959    @(Get-ChildItem $tokenCache -File -ErrorAction SilentlyContinue).Count
13960} else { 0 }
13961"TokenBrokerCacheFiles: $tokenCount"
13962# Office identity
13963$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
13964if (Test-Path $officeId) {
13965    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
13966    $signedIn = if ($id.SignedInUserId) { $id.SignedInUserId } else { 'None' }
13967    "OfficeSignedInUserId: $signedIn"
13968}
13969# Check if Teams is in startup
13970$startupKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
13971$teamsRun = (Get-ItemProperty $startupKey -ErrorAction SilentlyContinue) | Select-Object -ExpandProperty 'com.squirrel.Teams.Teams' -ErrorAction SilentlyContinue
13972"TeamsInStartup: $(if ($teamsRun) { 'Yes (Classic)' } else { 'Not in user run key' })"
13973"#;
13974    match run_powershell(ps_auth) {
13975        Ok(o) if !o.trim().is_empty() => {
13976            for line in o.lines().take(max_entries + 4) {
13977                let l = line.trim();
13978                if !l.is_empty() {
13979                    out.push_str(&format!("- {l}\n"));
13980                }
13981            }
13982        }
13983        _ => out.push_str("- Could not inspect Teams account state\n"),
13984    }
13985
13986    out.push_str("\n=== Audio and video device binding ===\n");
13987    let ps_devices = r#"
13988# Teams stores device prefs in the settings file
13989$settingsPaths = @(
13990    (Join-Path $env:APPDATA 'Microsoft\Teams\desktop-config.json'),
13991    (Join-Path $env:LOCALAPPDATA 'Packages\MSTeams_8wekyb3d8bbwe\LocalCache\Microsoft\MSTeams\app_settings.json')
13992)
13993$found = $false
13994foreach ($sp in $settingsPaths) {
13995    if (Test-Path $sp) {
13996        $found = $true
13997        $raw = try { Get-Content $sp -Raw -ErrorAction SilentlyContinue } catch { $null }
13998        if ($raw) {
13999            $json = try { $raw | ConvertFrom-Json -ErrorAction SilentlyContinue } catch { $null }
14000            if ($json) {
14001                $mic = if ($json.currentAudioDevice) { $json.currentAudioDevice } elseif ($json.audioDevice) { $json.audioDevice } else { 'Default' }
14002                $spk = if ($json.currentSpeakerDevice) { $json.currentSpeakerDevice } elseif ($json.speakerDevice) { $json.speakerDevice } else { 'Default' }
14003                $cam = if ($json.currentVideoDevice) { $json.currentVideoDevice } elseif ($json.videoDevice) { $json.videoDevice } else { 'Default' }
14004                "ConfigFile: $sp"
14005                "Microphone: $mic"
14006                "Speaker: $spk"
14007                "Camera: $cam"
14008            } else {
14009                "ConfigFile: $sp (not parseable as JSON)"
14010            }
14011        } else {
14012            "ConfigFile: $sp (empty)"
14013        }
14014        break
14015    }
14016}
14017if (-not $found) {
14018    "NoTeamsConfigFile: Teams device prefs not found -- Teams may not have been launched yet or uses system defaults"
14019}
14020"#;
14021    match run_powershell(ps_devices) {
14022        Ok(o) if !o.trim().is_empty() => {
14023            for line in o.lines().take(max_entries + 4) {
14024                let l = line.trim();
14025                if !l.is_empty() {
14026                    out.push_str(&format!("- {l}\n"));
14027                }
14028            }
14029        }
14030        _ => out.push_str("- Could not inspect Teams device binding\n"),
14031    }
14032
14033    out.push_str("\n=== Recent crash and event evidence (7d) ===\n");
14034    let ps_events = r#"
14035$cutoff = (Get-Date).AddDays(-7)
14036$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14037    Where-Object {
14038        $msg = [string]$_.Message
14039        ($_.ProviderName -eq 'Application Error' -or $_.ProviderName -eq 'Windows Error Reporting') -and
14040        ($msg.ToLower().Contains('teams') -or $msg.ToLower().Contains('ms-teams') -or $msg.ToLower().Contains('msteams'))
14041    } |
14042    Select-Object -First 8
14043if ($events) {
14044    foreach ($event in $events) {
14045        $msg = ($event.Message -replace '\s+', ' ')
14046        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14047        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14048    }
14049} else {
14050    "No recent Teams crash or error events detected in Application log"
14051}
14052"#;
14053    match run_powershell(ps_events) {
14054        Ok(o) if !o.trim().is_empty() => {
14055            for line in o.lines().take(max_entries + 4) {
14056                let l = line.trim();
14057                if !l.is_empty() {
14058                    out.push_str(&format!("- {l}\n"));
14059                }
14060            }
14061        }
14062        _ => out.push_str("- Could not inspect Teams event log evidence\n"),
14063    }
14064
14065    let mut findings: Vec<String> = Vec::new();
14066
14067    let classic_installed = out.contains("- ClassicTeams: Installed");
14068    let new_installed = out.contains("- NewTeams: Installed");
14069    if !classic_installed && !new_installed {
14070        findings.push("Neither classic Teams nor new Teams is installed on this machine.".into());
14071    }
14072
14073    for name in ["Teams", "ms-teams"] {
14074        let marker = format!("{name} | Running: Yes | Processes:");
14075        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14076            let ws_mb = line
14077                .split("WorkingSetMB: ")
14078                .nth(1)
14079                .and_then(|v| v.trim().parse::<f64>().ok())
14080                .unwrap_or(0.0);
14081            if ws_mb >= 1000.0 {
14082                findings.push(format!(
14083                    "{name} is consuming {ws_mb:.0} MB of RAM — cache bloat or a large number of channels/meetings loaded may be driving memory growth."
14084                ));
14085            }
14086        }
14087    }
14088
14089    for (label, threshold_mb) in [
14090        ("ClassicTeamsCache", 500.0_f64),
14091        ("ClassicTeamsSquirrel", 2000.0),
14092        ("NewTeamsCache", 500.0),
14093        ("NewTeamsAppData", 3000.0),
14094    ] {
14095        let marker = format!("{label} |");
14096        if let Some(line) = out.lines().find(|l| l.contains(&marker)) {
14097            let mb = line
14098                .split("SizeMB: ")
14099                .nth(1)
14100                .and_then(|v| v.trim().parse::<f64>().ok())
14101                .unwrap_or(0.0);
14102            if mb >= threshold_mb {
14103                findings.push(format!(
14104                    "{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."
14105                ));
14106            }
14107        }
14108    }
14109
14110    if out.contains("- Installed: No -- New Teams") {
14111        findings.push(
14112            "WebView2 runtime is missing — new Teams requires WebView2 for rendering. Install it from Microsoft's WebView2 page or via winget install Microsoft.EdgeWebView2Runtime."
14113                .into(),
14114        );
14115    }
14116
14117    if out.contains("- ClassicTeamsAccount: Not configured")
14118        && out.contains("- OfficeSignedInUserId: None")
14119    {
14120        findings.push(
14121            "No Teams account is configured and Office sign-in is absent — Teams will fail to load meetings or channels until the user signs in."
14122                .into(),
14123        );
14124    }
14125
14126    if out.contains("Application Error |") || out.contains("Windows Error Reporting |") {
14127        findings.push(
14128            "Recent Teams crash evidence found in the Application event log — check the event lines below for the faulting module."
14129                .into(),
14130        );
14131    }
14132
14133    let mut result = String::from("Host inspection: teams\n\n=== Findings ===\n");
14134    if findings.is_empty() {
14135        result.push_str("- No obvious Teams health blocker detected.\n");
14136    } else {
14137        for finding in &findings {
14138            result.push_str(&format!("- Finding: {finding}\n"));
14139        }
14140    }
14141    result.push('\n');
14142    result.push_str(&out);
14143    Ok(result)
14144}
14145
14146#[cfg(not(windows))]
14147fn inspect_teams(_max_entries: usize) -> Result<String, String> {
14148    Ok(
14149        "Host inspection: teams\n\n=== Findings ===\n- Teams health inspection is Windows-only.\n"
14150            .into(),
14151    )
14152}
14153
14154#[cfg(windows)]
14155fn inspect_identity_auth(max_entries: usize) -> Result<String, String> {
14156    let mut out = String::from("=== Identity broker services ===\n");
14157
14158    let ps_services = r#"
14159$serviceNames = 'TokenBroker','wlidsvc','OneAuth'
14160foreach ($name in $serviceNames) {
14161    $svc = Get-CimInstance Win32_Service -Filter "Name='$name'" -ErrorAction SilentlyContinue
14162    if ($svc) {
14163        "$($svc.Name) | Status: $($svc.State) | StartMode: $($svc.StartMode)"
14164    } else {
14165        "$name | Not found"
14166    }
14167}
14168"#;
14169    match run_powershell(ps_services) {
14170        Ok(o) if !o.trim().is_empty() => {
14171            for line in o.lines().take(max_entries) {
14172                let l = line.trim();
14173                if !l.is_empty() {
14174                    out.push_str(&format!("- {l}\n"));
14175                }
14176            }
14177        }
14178        _ => out.push_str("- Could not inspect identity broker services\n"),
14179    }
14180
14181    out.push_str("\n=== Device registration ===\n");
14182    let ps_device = r#"
14183$dsreg = Get-Command dsregcmd.exe -ErrorAction SilentlyContinue
14184if ($dsreg) {
14185    try {
14186        $raw = & $dsreg.Source /status 2>$null
14187        $text = ($raw -join "`n")
14188        $keys = 'AzureAdJoined','WorkplaceJoined','DomainJoined','DeviceAuthStatus','TenantName','AzureAdPrt','WamDefaultSet'
14189        $seen = $false
14190        foreach ($key in $keys) {
14191            $match = [regex]::Match($text, '(?im)^\s*' + [regex]::Escape($key) + '\s*:\s*(.+)$')
14192            if ($match.Success) {
14193                "${key}: $($match.Groups[1].Value.Trim())"
14194                $seen = $true
14195            }
14196        }
14197        if (-not $seen) {
14198            "DeviceRegistration: dsregcmd returned no recognizable registration fields (common on personal or unmanaged devices)"
14199        }
14200    } catch {
14201        "DeviceRegistration: dsregcmd failed - $($_.Exception.Message)"
14202    }
14203} else {
14204    "DeviceRegistration: dsregcmd unavailable"
14205}
14206"#;
14207    match run_powershell(ps_device) {
14208        Ok(o) if !o.trim().is_empty() => {
14209            for line in o.lines().take(max_entries + 4) {
14210                let l = line.trim();
14211                if !l.is_empty() {
14212                    out.push_str(&format!("- {l}\n"));
14213                }
14214            }
14215        }
14216        _ => out.push_str(
14217            "- DeviceRegistration: Could not inspect device registration state in this session\n",
14218        ),
14219    }
14220
14221    out.push_str("\n=== Broker packages and caches ===\n");
14222    let ps_broker = r#"
14223$pkg = Get-AppxPackage -Name 'Microsoft.AAD.BrokerPlugin' -ErrorAction SilentlyContinue | Select-Object -First 1
14224if ($pkg) {
14225    "AADBrokerPlugin: Installed | Version: $($pkg.Version)"
14226} else {
14227    "AADBrokerPlugin: Not installed"
14228}
14229$tokenCache = Join-Path $env:LOCALAPPDATA 'Microsoft\TokenBroker\Cache'
14230$tokenCount = if (Test-Path $tokenCache) { @(Get-ChildItem $tokenCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14231"TokenBrokerCacheFiles: $tokenCount"
14232$identityCache = Join-Path $env:LOCALAPPDATA 'Microsoft\IdentityCache'
14233$identityCount = if (Test-Path $identityCache) { @(Get-ChildItem $identityCache -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14234"IdentityCacheFiles: $identityCount"
14235$oneAuth = Join-Path $env:LOCALAPPDATA 'Microsoft\OneAuth'
14236$oneAuthCount = if (Test-Path $oneAuth) { @(Get-ChildItem $oneAuth -File -Recurse -ErrorAction SilentlyContinue).Count } else { 0 }
14237"OneAuthFiles: $oneAuthCount"
14238"#;
14239    match run_powershell(ps_broker) {
14240        Ok(o) if !o.trim().is_empty() => {
14241            for line in o.lines().take(max_entries + 4) {
14242                let l = line.trim();
14243                if !l.is_empty() {
14244                    out.push_str(&format!("- {l}\n"));
14245                }
14246            }
14247        }
14248        _ => out.push_str("- Could not inspect identity broker packages or caches\n"),
14249    }
14250
14251    out.push_str("\n=== Microsoft app account signals ===\n");
14252    let ps_accounts = r#"
14253function MaskEmail([string]$Email) {
14254    if ([string]::IsNullOrWhiteSpace($Email) -or $Email -notmatch '@') { return 'Unknown' }
14255    $parts = $Email.Split('@', 2)
14256    $local = $parts[0]
14257    $domain = $parts[1]
14258    if ($local.Length -le 1) { return "*@$domain" }
14259    return ($local.Substring(0,1) + "***@" + $domain)
14260}
14261$allAccounts = @()
14262$officeId = 'HKCU:\Software\Microsoft\Office\16.0\Common\Identity'
14263if (Test-Path $officeId) {
14264    $id = Get-ItemProperty $officeId -ErrorAction SilentlyContinue
14265    if ($id.SignedInUserId) {
14266        $allAccounts += [string]$id.SignedInUserId
14267        "OfficeSignedInUserId: $(MaskEmail ([string]$id.SignedInUserId))"
14268    } else {
14269        "OfficeSignedInUserId: None"
14270    }
14271} else {
14272    "OfficeSignedInUserId: Not configured"
14273}
14274$teamsAcct = 'HKCU:\Software\Microsoft\Office\Teams'
14275if (Test-Path $teamsAcct) {
14276    $item = Get-ItemProperty $teamsAcct -ErrorAction SilentlyContinue
14277    $email = if ($item.HomeUserUpn) { [string]$item.HomeUserUpn } elseif ($item.LoggedInEmail) { [string]$item.LoggedInEmail } else { '' }
14278    if (-not [string]::IsNullOrWhiteSpace($email)) {
14279        $allAccounts += $email
14280        "TeamsAccount: $(MaskEmail $email)"
14281    } else {
14282        "TeamsAccount: Unknown"
14283    }
14284} else {
14285    "TeamsAccount: Not configured"
14286}
14287$oneDriveBase = 'HKCU:\Software\Microsoft\OneDrive\Accounts'
14288$oneDriveEmails = @()
14289if (Test-Path $oneDriveBase) {
14290    $oneDriveEmails = Get-ChildItem $oneDriveBase -ErrorAction SilentlyContinue |
14291        ForEach-Object {
14292            $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue
14293            if ($p.UserEmail) { [string]$p.UserEmail }
14294        } |
14295        Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
14296        Sort-Object -Unique
14297}
14298$allAccounts += $oneDriveEmails
14299"OneDriveAccountCount: $(@($oneDriveEmails).Count)"
14300if (@($oneDriveEmails).Count -gt 0) {
14301    "OneDriveAccounts: $((@($oneDriveEmails) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14302}
14303$distinct = @($allAccounts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
14304"DistinctIdentityCount: $($distinct.Count)"
14305if ($distinct.Count -gt 0) {
14306    "IdentitySet: $((@($distinct) | ForEach-Object { MaskEmail $_ }) -join ', ')"
14307}
14308"#;
14309    match run_powershell(ps_accounts) {
14310        Ok(o) if !o.trim().is_empty() => {
14311            for line in o.lines().take(max_entries + 6) {
14312                let l = line.trim();
14313                if !l.is_empty() {
14314                    out.push_str(&format!("- {l}\n"));
14315                }
14316            }
14317        }
14318        _ => out.push_str("- Could not inspect Microsoft app identity state\n"),
14319    }
14320
14321    out.push_str("\n=== WebView2 auth dependency ===\n");
14322    let ps_webview = r#"
14323$paths = @(
14324    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft\EdgeWebView\Application'),
14325    (Join-Path $env:ProgramFiles 'Microsoft\EdgeWebView\Application')
14326) | Where-Object { $_ -and (Test-Path $_) }
14327$runtimeDir = $paths | ForEach-Object {
14328    Get-ChildItem $_ -Directory -ErrorAction SilentlyContinue |
14329        Where-Object { $_.Name -match '^\d+\.' } |
14330        Sort-Object Name -Descending |
14331        Select-Object -First 1
14332} | Select-Object -First 1
14333if ($runtimeDir) {
14334    $exe = Join-Path $runtimeDir.FullName 'msedgewebview2.exe'
14335    $version = if (Test-Path $exe) { try { (Get-Item $exe).VersionInfo.FileVersion } catch { $runtimeDir.Name } } else { $runtimeDir.Name }
14336    "WebView2: Installed | Version: $version"
14337} else {
14338    "WebView2: Not installed"
14339}
14340"#;
14341    match run_powershell(ps_webview) {
14342        Ok(o) if !o.trim().is_empty() => {
14343            for line in o.lines().take(4) {
14344                let l = line.trim();
14345                if !l.is_empty() {
14346                    out.push_str(&format!("- {l}\n"));
14347                }
14348            }
14349        }
14350        _ => out.push_str("- Could not inspect WebView2 runtime\n"),
14351    }
14352
14353    out.push_str("\n=== Recent auth-related events (24h) ===\n");
14354    let ps_events = r#"
14355try {
14356    $cutoff = (Get-Date).AddHours(-24)
14357    $events = @()
14358    if (Get-WinEvent -ListLog 'Microsoft-Windows-AAD/Operational' -ErrorAction SilentlyContinue) {
14359        $events += Get-WinEvent -FilterHashtable @{ LogName='Microsoft-Windows-AAD/Operational'; StartTime=$cutoff } -MaxEvents 30 -ErrorAction SilentlyContinue |
14360            Where-Object { $_.LevelDisplayName -in @('Error','Warning') } |
14361            Select-Object -First 4
14362    }
14363    $events += Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 80 -ErrorAction SilentlyContinue |
14364        Where-Object {
14365            ($_.LevelDisplayName -in @('Error','Warning')) -and (
14366                $_.ProviderName -match 'Outlook|Teams|OneDrive|Office|AAD|TokenBroker|Broker'
14367                -or $_.Message -match 'Outlook|Teams|OneDrive|sign-?in|authentication|TokenBroker|BrokerPlugin|AAD'
14368            )
14369        } |
14370        Select-Object -First 6
14371    $events = $events | Sort-Object TimeCreated -Descending | Select-Object -First 8
14372    "AuthEventCount: $(@($events).Count)"
14373    if ($events) {
14374        foreach ($e in $events) {
14375            $msg = if ([string]::IsNullOrWhiteSpace([string]$e.Message)) {
14376                'No message'
14377            } else {
14378                ($e.Message -replace '\r','' -split '\n')[0] -replace '\|','/'
14379            }
14380            "$($e.TimeCreated.ToString('MM-dd HH:mm')) | Provider: $($e.ProviderName) | Level: $($e.LevelDisplayName) | Id: $($e.Id) | $msg"
14381        }
14382    } else {
14383        "No auth-related warning/error events detected"
14384    }
14385} catch {
14386    "AuthEventStatus: Could not inspect auth-related events - $($_.Exception.Message)"
14387}
14388"#;
14389    match run_powershell(ps_events) {
14390        Ok(o) if !o.trim().is_empty() => {
14391            for line in o.lines().take(max_entries + 8) {
14392                let l = line.trim();
14393                if !l.is_empty() {
14394                    out.push_str(&format!("- {l}\n"));
14395                }
14396            }
14397        }
14398        _ => out
14399            .push_str("- AuthEventStatus: Could not inspect auth-related events in this session\n"),
14400    }
14401
14402    let parse_count = |prefix: &str| -> Option<u64> {
14403        out.lines().find_map(|line| {
14404            line.trim()
14405                .strip_prefix(prefix)
14406                .and_then(|value| value.trim().parse::<u64>().ok())
14407        })
14408    };
14409
14410    let distinct_identity_count = parse_count("- DistinctIdentityCount: ").unwrap_or(0);
14411    let auth_event_count = parse_count("- AuthEventCount: ").unwrap_or(0);
14412
14413    let mut findings: Vec<String> = Vec::new();
14414    if out.contains("TokenBroker | Status: Stopped")
14415        || out.contains("wlidsvc | Status: Stopped")
14416        || out.contains("OneAuth | Status: Stopped")
14417    {
14418        findings.push(
14419            "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."
14420                .into(),
14421        );
14422    }
14423    if out.contains("AADBrokerPlugin: Not installed") {
14424        findings.push(
14425            "Microsoft AAD Broker Plugin is missing - work/school account sign-in and token refresh can fail without the broker package."
14426                .into(),
14427        );
14428    }
14429    if out.contains("WebView2: Not installed") {
14430        findings.push(
14431            "WebView2 runtime is missing - modern Microsoft 365 sign-in surfaces may fail or render badly without it."
14432                .into(),
14433        );
14434    }
14435    if distinct_identity_count > 1 {
14436        findings.push(format!(
14437            "{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."
14438        ));
14439    }
14440    if (out.contains("AzureAdJoined: NO") || out.contains("WorkplaceJoined: NO"))
14441        && distinct_identity_count > 0
14442    {
14443        findings.push(
14444            "This machine shows Microsoft app identities but weak device-registration signals - organizational SSO, Conditional Access, or silent token refresh may be limited."
14445                .into(),
14446        );
14447    }
14448    if out.contains("DeviceRegistration: dsregcmd")
14449        || out.contains("DeviceRegistration: Could not inspect device registration state")
14450    {
14451        findings.push(
14452            "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."
14453                .into(),
14454        );
14455    }
14456    if auth_event_count > 0 {
14457        findings.push(format!(
14458            "{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."
14459        ));
14460    } else if out.contains("AuthEventStatus: Could not inspect auth-related events") {
14461        findings.push(
14462            "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."
14463                .into(),
14464        );
14465    }
14466
14467    let mut result = String::from("Host inspection: identity_auth\n\n=== Findings ===\n");
14468    if findings.is_empty() {
14469        result.push_str("- No obvious Microsoft 365 identity broker, token cache, or device-registration blocker detected.\n");
14470    } else {
14471        for finding in &findings {
14472            result.push_str(&format!("- Finding: {finding}\n"));
14473        }
14474    }
14475    result.push('\n');
14476    result.push_str(&out);
14477    Ok(result)
14478}
14479
14480#[cfg(not(windows))]
14481fn inspect_identity_auth(_max_entries: usize) -> Result<String, String> {
14482    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())
14483}
14484
14485#[cfg(windows)]
14486fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
14487    let mut out = String::from("=== File History ===\n");
14488
14489    let ps_fh = r#"
14490$svc = Get-Service fhsvc -ErrorAction SilentlyContinue
14491if ($svc) {
14492    "FileHistoryService: $($svc.Status) | StartType: $($svc.StartType)"
14493} else {
14494    "FileHistoryService: Not found"
14495}
14496# File History config in registry
14497$fhKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SPP\UserPolicy\S-1-5-21*\d5c93fba*'
14498$fhUser = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\FileHistory'
14499if (Test-Path $fhUser) {
14500    $fh = Get-ItemProperty $fhUser -ErrorAction SilentlyContinue
14501    $enabled = if ($fh.Enabled -eq 0) { 'Disabled' } elseif ($fh.Enabled -eq 1) { 'Enabled' } else { 'Unknown' }
14502    $target = if ($fh.TargetUrl) { $fh.TargetUrl } else { 'Not configured' }
14503    $lastBackup = if ($fh.ProtectedUpToTime) {
14504        try { [DateTime]::FromFileTime($fh.ProtectedUpToTime).ToString('yyyy-MM-dd HH:mm') } catch { 'Unknown' }
14505    } else { 'Never' }
14506    "Enabled: $enabled"
14507    "BackupDrive: $target"
14508    "LastBackup: $lastBackup"
14509} else {
14510    "Enabled: Not configured"
14511    "BackupDrive: Not configured"
14512    "LastBackup: Never"
14513}
14514"#;
14515    match run_powershell(ps_fh) {
14516        Ok(o) if !o.trim().is_empty() => {
14517            for line in o.lines().take(6) {
14518                let l = line.trim();
14519                if !l.is_empty() {
14520                    out.push_str(&format!("- {l}\n"));
14521                }
14522            }
14523        }
14524        _ => out.push_str("- Could not inspect File History state\n"),
14525    }
14526
14527    out.push_str("\n=== Windows Backup (wbadmin) ===\n");
14528    let ps_wbadmin = r#"
14529$svc = Get-Service wbengine -ErrorAction SilentlyContinue
14530"WindowsBackupEngine: $(if ($svc) { "$($svc.Status) | StartType: $($svc.StartType)" } else { 'Not found' })"
14531# Last backup from wbadmin
14532$raw = try { wbadmin get versions 2>&1 | Select-Object -First 30 } catch { $null }
14533if ($raw -and ($raw -join ' ') -notmatch 'no backup') {
14534    $lastDate = ($raw | Select-String 'Backup time:' | Select-Object -First 1).Line
14535    $lastTarget = ($raw | Select-String 'Backup target:' | Select-Object -First 1).Line
14536    if ($lastDate) { $lastDate.Trim() }
14537    if ($lastTarget) { $lastTarget.Trim() }
14538} else {
14539    "LastWbadminBackup: No backup versions found"
14540}
14541# Task-based backup
14542$task = Get-ScheduledTask -TaskPath '\Microsoft\Windows\WindowsBackup\' -ErrorAction SilentlyContinue
14543foreach ($t in $task) {
14544    "BackupTask: $($t.TaskName) | State: $($t.State)"
14545}
14546"#;
14547    match run_powershell(ps_wbadmin) {
14548        Ok(o) if !o.trim().is_empty() => {
14549            for line in o.lines().take(8) {
14550                let l = line.trim();
14551                if !l.is_empty() {
14552                    out.push_str(&format!("- {l}\n"));
14553                }
14554            }
14555        }
14556        _ => out.push_str("- Could not inspect Windows Backup state\n"),
14557    }
14558
14559    out.push_str("\n=== System Restore ===\n");
14560    let ps_sr = r#"
14561$drives = Get-WmiObject -Class Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction SilentlyContinue |
14562    Select-Object -ExpandProperty DeviceID
14563foreach ($drive in $drives) {
14564    $protection = try {
14565        (Get-ComputerRestorePoint -Drive "$drive\" -ErrorAction SilentlyContinue)
14566    } catch { $null }
14567    $srReg = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore"
14568    $rpConf = try {
14569        Get-ItemProperty "$srReg" -ErrorAction SilentlyContinue
14570    } catch { $null }
14571    # Check if SR is disabled for this drive
14572    $disabled = $false
14573    $vssService = Get-Service VSS -ErrorAction SilentlyContinue
14574    "Drive: $drive | VSSService: $(if ($vssService) { $vssService.Status } else { 'Not found' })"
14575}
14576# Most recent restore point
14577$points = try { Get-ComputerRestorePoint -ErrorAction SilentlyContinue } catch { $null }
14578if ($points) {
14579    $latest = $points | Sort-Object SequenceNumber -Descending | Select-Object -First 1
14580    $date = try { [Management.ManagementDateTimeConverter]::ToDateTime($latest.CreationTime).ToString('yyyy-MM-dd HH:mm') } catch { $latest.CreationTime }
14581    "MostRecentRestorePoint: $($latest.Description) | Created: $date"
14582} else {
14583    "MostRecentRestorePoint: None found"
14584}
14585$srEnabled = try {
14586    $regVal = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' -ErrorAction SilentlyContinue).RPSessionInterval
14587    if ($null -eq $regVal) { 'Enabled (default)' } elseif ($regVal -eq 0) { 'Disabled' } else { "Interval: $regVal" }
14588} catch { 'Unknown' }
14589"SystemRestoreState: $srEnabled"
14590"#;
14591    match run_powershell(ps_sr) {
14592        Ok(o) if !o.trim().is_empty() => {
14593            for line in o.lines().take(8) {
14594                let l = line.trim();
14595                if !l.is_empty() {
14596                    out.push_str(&format!("- {l}\n"));
14597                }
14598            }
14599        }
14600        _ => out.push_str("- Could not inspect System Restore state\n"),
14601    }
14602
14603    out.push_str("\n=== OneDrive backup (Known Folder Move) ===\n");
14604    let ps_kfm = r#"
14605$kfmKey = 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts'
14606if (Test-Path $kfmKey) {
14607    $accounts = Get-ChildItem $kfmKey -ErrorAction SilentlyContinue
14608    foreach ($acct in $accounts | Select-Object -First 3) {
14609        $props = Get-ItemProperty $acct.PSPath -ErrorAction SilentlyContinue
14610        $email = $props.UserEmail
14611        $kfmDesktop = $props.'KFMSilentOptInDesktop'
14612        $kfmDocs = $props.'KFMSilentOptInDocuments'
14613        $kfmPics = $props.'KFMSilentOptInPictures'
14614        "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' })"
14615    }
14616} else {
14617    "OneDriveKFM: No OneDrive accounts found"
14618}
14619"#;
14620    match run_powershell(ps_kfm) {
14621        Ok(o) if !o.trim().is_empty() => {
14622            for line in o.lines().take(6) {
14623                let l = line.trim();
14624                if !l.is_empty() {
14625                    out.push_str(&format!("- {l}\n"));
14626                }
14627            }
14628        }
14629        _ => out.push_str("- Could not inspect OneDrive Known Folder Move state\n"),
14630    }
14631
14632    out.push_str("\n=== Recent backup failure events (7d) ===\n");
14633    let ps_events = r#"
14634$cutoff = (Get-Date).AddDays(-7)
14635$events = Get-WinEvent -FilterHashtable @{ LogName='Application'; StartTime=$cutoff } -MaxEvents 500 -ErrorAction SilentlyContinue |
14636    Where-Object {
14637        $_.ProviderName -match 'backup|FileHistory|wbengine|Microsoft-Windows-Backup' -or
14638        ($_.Id -in @(49,50,517,521) -and $_.LogName -eq 'Application')
14639    } |
14640    Where-Object { $_.Level -le 3 } |
14641    Select-Object -First 6
14642if ($events) {
14643    foreach ($event in $events) {
14644        $msg = ($event.Message -replace '\s+', ' ')
14645        if ($msg.Length -gt 140) { $msg = $msg.Substring(0, 140) }
14646        "$($event.TimeCreated.ToString('MM-dd HH:mm')) | $($event.ProviderName) | EventId: $($event.Id) | $msg"
14647    }
14648} else {
14649    "No recent backup failure events detected"
14650}
14651"#;
14652    match run_powershell(ps_events) {
14653        Ok(o) if !o.trim().is_empty() => {
14654            for line in o.lines().take(8) {
14655                let l = line.trim();
14656                if !l.is_empty() {
14657                    out.push_str(&format!("- {l}\n"));
14658                }
14659            }
14660        }
14661        _ => out.push_str("- Could not inspect backup failure events\n"),
14662    }
14663
14664    let mut findings: Vec<String> = Vec::new();
14665
14666    let fh_enabled = out.contains("- Enabled: Enabled");
14667    let fh_never =
14668        out.contains("- LastBackup: Never") || out.contains("- LastBackup: Not configured");
14669    let no_wbadmin = out.contains("No backup versions found");
14670    let no_restore_point = out.contains("MostRecentRestorePoint: None found");
14671
14672    if !fh_enabled && no_wbadmin {
14673        findings.push(
14674            "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(),
14675        );
14676    } else if fh_enabled && fh_never {
14677        findings.push(
14678            "File History is enabled but has never completed a backup — check that the backup drive is connected and accessible.".into(),
14679        );
14680    }
14681
14682    if no_restore_point {
14683        findings.push(
14684            "No System Restore points exist — if a driver or update goes wrong there is no local rollback point available.".into(),
14685        );
14686    }
14687
14688    if out.contains("- FileHistoryService: Stopped")
14689        || out.contains("- FileHistoryService: Not found")
14690    {
14691        findings.push(
14692            "File History service (fhsvc) is stopped or missing — File History backups cannot run until the service is started.".into(),
14693        );
14694    }
14695
14696    if out.contains("Application Error |")
14697        || out.contains("Microsoft-Windows-Backup |")
14698        || out.contains("wbengine |")
14699    {
14700        findings.push(
14701            "Recent backup failure events found in the Application log — check the event lines below for the specific error.".into(),
14702        );
14703    }
14704
14705    let mut result = String::from("Host inspection: windows_backup\n\n=== Findings ===\n");
14706    if findings.is_empty() {
14707        result.push_str("- No obvious backup health blocker detected.\n");
14708    } else {
14709        for finding in &findings {
14710            result.push_str(&format!("- Finding: {finding}\n"));
14711        }
14712    }
14713    result.push('\n');
14714    result.push_str(&out);
14715    Ok(result)
14716}
14717
14718#[cfg(not(windows))]
14719fn inspect_windows_backup(_max_entries: usize) -> Result<String, String> {
14720    Ok("Host inspection: windows_backup\n\n=== Findings ===\n- Windows Backup inspection is Windows-only.\n".into())
14721}
14722
14723#[cfg(windows)]
14724fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
14725    let mut out = String::from("=== Windows Search service ===\n");
14726
14727    // Service state
14728    let ps_svc = r#"
14729$svc = Get-Service WSearch -ErrorAction SilentlyContinue
14730if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
14731else { "WSearch service not found" }
14732"#;
14733    match run_powershell(ps_svc) {
14734        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
14735        Err(_) => out.push_str("- Could not query WSearch service\n"),
14736    }
14737
14738    // Indexer state via registry
14739    out.push_str("\n=== Indexer state ===\n");
14740    let ps_idx = r#"
14741$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
14742$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
14743if ($props) {
14744    "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
14745    "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
14746    "DataDirectory: $($props.DataDirectory)"
14747} else { "Registry key not found" }
14748"#;
14749    match run_powershell(ps_idx) {
14750        Ok(o) => {
14751            for line in o.lines() {
14752                let l = line.trim();
14753                if !l.is_empty() {
14754                    out.push_str(&format!("- {l}\n"));
14755                }
14756            }
14757        }
14758        Err(_) => out.push_str("- Could not read indexer registry\n"),
14759    }
14760
14761    // Indexed locations
14762    out.push_str("\n=== Indexed locations ===\n");
14763    let ps_locs = r#"
14764$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
14765if ($comObj) {
14766    $catalog = $comObj.GetCatalog('SystemIndex')
14767    $manager = $catalog.GetCrawlScopeManager()
14768    $rules = $manager.EnumerateRoots()
14769    while ($true) {
14770        try {
14771            $root = $rules.Next(1)
14772            if ($root.Count -eq 0) { break }
14773            $r = $root[0]
14774            "  $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
14775        } catch { break }
14776    }
14777} else { "  COM admin interface not available (normal on non-admin sessions)" }
14778"#;
14779    match run_powershell(ps_locs) {
14780        Ok(o) if !o.trim().is_empty() => {
14781            for line in o.lines() {
14782                let l = line.trim_end();
14783                if !l.is_empty() {
14784                    out.push_str(&format!("{l}\n"));
14785                }
14786            }
14787        }
14788        _ => {
14789            // Fallback: read from registry
14790            let ps_reg = r#"
14791Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
14792ForEach-Object { "  $($_.PSChildName)" } | Select-Object -First 20
14793"#;
14794            match run_powershell(ps_reg) {
14795                Ok(o) if !o.trim().is_empty() => {
14796                    for line in o.lines() {
14797                        let l = line.trim_end();
14798                        if !l.is_empty() {
14799                            out.push_str(&format!("{l}\n"));
14800                        }
14801                    }
14802                }
14803                _ => out.push_str("  - Could not enumerate indexed locations\n"),
14804            }
14805        }
14806    }
14807
14808    // Recent indexing errors from event log
14809    out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
14810    let ps_evts = r#"
14811Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
14812Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
14813ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
14814"#;
14815    match run_powershell(ps_evts) {
14816        Ok(o) if !o.trim().is_empty() => {
14817            for line in o.lines() {
14818                let l = line.trim();
14819                if !l.is_empty() {
14820                    out.push_str(&format!("- {l}\n"));
14821                }
14822            }
14823        }
14824        _ => out.push_str("- No recent indexer errors found\n"),
14825    }
14826
14827    let mut findings: Vec<String> = Vec::new();
14828    if out.contains("Status: Stopped") {
14829        findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
14830    }
14831    if out.contains("IsContentIndexingEnabled: 0")
14832        || out.contains("IsContentIndexingEnabled: False")
14833    {
14834        findings.push(
14835            "Content indexing is disabled — file content won't be searchable, only filenames."
14836                .into(),
14837        );
14838    }
14839    if out.contains("SetupCompletedSuccessfully: 0")
14840        || out.contains("SetupCompletedSuccessfully: False")
14841    {
14842        findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
14843    }
14844
14845    let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
14846    if findings.is_empty() {
14847        result.push_str("- Windows Search service and indexer appear healthy.\n");
14848        result.push_str("  If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
14849    } else {
14850        for f in &findings {
14851            result.push_str(&format!("- Finding: {f}\n"));
14852        }
14853    }
14854    result.push('\n');
14855    result.push_str(&out);
14856    Ok(result)
14857}
14858
14859#[cfg(not(windows))]
14860fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
14861    Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
14862}
14863
14864// ── inspect_display_config ────────────────────────────────────────────────────
14865
14866#[cfg(windows)]
14867fn inspect_display_config(max_entries: usize) -> Result<String, String> {
14868    let mut out = String::new();
14869
14870    // Active displays via CIM
14871    out.push_str("=== Active displays ===\n");
14872    let ps_displays = r#"
14873Get-CimInstance -ClassName CIM_VideoControllerResolution -ErrorAction SilentlyContinue |
14874Select-Object -First 20 |
14875ForEach-Object {
14876    "$($_.HorizontalResolution)x$($_.VerticalResolution) @ $($_.RefreshRate)Hz | Colors: $($_.NumberOfColors)"
14877}
14878"#;
14879    match run_powershell(ps_displays) {
14880        Ok(o) if !o.trim().is_empty() => {
14881            for line in o.lines().take(max_entries) {
14882                let l = line.trim();
14883                if !l.is_empty() {
14884                    out.push_str(&format!("- {l}\n"));
14885                }
14886            }
14887        }
14888        _ => out.push_str("- Could not enumerate display resolutions via CIM\n"),
14889    }
14890
14891    // GPU / video adapter
14892    out.push_str("\n=== Video adapters ===\n");
14893    let ps_gpu = r#"
14894Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | Select-Object -First 4 |
14895ForEach-Object {
14896    $res = "$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
14897    $hz  = "$($_.CurrentRefreshRate) Hz"
14898    $bits = "$($_.CurrentBitsPerPixel) bpp"
14899    "$($_.Name) | $res @ $hz | $bits | Driver: $($_.DriverVersion)"
14900}
14901"#;
14902    match run_powershell(ps_gpu) {
14903        Ok(o) if !o.trim().is_empty() => {
14904            for line in o.lines().take(max_entries) {
14905                let l = line.trim();
14906                if !l.is_empty() {
14907                    out.push_str(&format!("- {l}\n"));
14908                }
14909            }
14910        }
14911        _ => out.push_str("- Could not query video adapter info\n"),
14912    }
14913
14914    // Monitor names via Win32_DesktopMonitor
14915    out.push_str("\n=== Connected monitors ===\n");
14916    let ps_monitors = r#"
14917Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | Select-Object -First 8 |
14918ForEach-Object { "$($_.Name) | Status: $($_.Status) | PnP: $($_.PNPDeviceID)" }
14919"#;
14920    match run_powershell(ps_monitors) {
14921        Ok(o) if !o.trim().is_empty() => {
14922            for line in o.lines().take(max_entries) {
14923                let l = line.trim();
14924                if !l.is_empty() {
14925                    out.push_str(&format!("- {l}\n"));
14926                }
14927            }
14928        }
14929        _ => out.push_str("- No monitor info available via WMI\n"),
14930    }
14931
14932    // DPI scaling
14933    out.push_str("\n=== DPI / scaling ===\n");
14934    let ps_dpi = r#"
14935Add-Type -TypeDefinition @'
14936using System; using System.Runtime.InteropServices;
14937public class DPI {
14938    [DllImport("user32")] public static extern IntPtr GetDC(IntPtr hwnd);
14939    [DllImport("gdi32")]  public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
14940    [DllImport("user32")] public static extern int ReleaseDC(IntPtr hwnd, IntPtr hdc);
14941}
14942'@ -ErrorAction SilentlyContinue
14943try {
14944    $hdc  = [DPI]::GetDC([IntPtr]::Zero)
14945    $dpiX = [DPI]::GetDeviceCaps($hdc, 88)
14946    $dpiY = [DPI]::GetDeviceCaps($hdc, 90)
14947    [DPI]::ReleaseDC([IntPtr]::Zero, $hdc) | Out-Null
14948    $scale = [Math]::Round($dpiX / 96.0 * 100)
14949    "DPI: ${dpiX}x${dpiY} | Scale: ${scale}%"
14950} catch { "DPI query unavailable" }
14951"#;
14952    match run_powershell(ps_dpi) {
14953        Ok(o) if !o.trim().is_empty() => {
14954            out.push_str(&format!("- {}\n", o.trim()));
14955        }
14956        _ => out.push_str("- DPI info unavailable\n"),
14957    }
14958
14959    let mut findings: Vec<String> = Vec::new();
14960    if out.contains("0x0") || out.contains("@ 0 Hz") {
14961        findings.push("One or more adapters report zero resolution or refresh rate — display may be asleep or misconfigured.".into());
14962    }
14963
14964    let mut result = String::from("Host inspection: display_config\n\n=== Findings ===\n");
14965    if findings.is_empty() {
14966        result.push_str("- Display configuration appears normal.\n");
14967    } else {
14968        for f in &findings {
14969            result.push_str(&format!("- Finding: {f}\n"));
14970        }
14971    }
14972    result.push('\n');
14973    result.push_str(&out);
14974    Ok(result)
14975}
14976
14977#[cfg(not(windows))]
14978fn inspect_display_config(_max_entries: usize) -> Result<String, String> {
14979    Ok("Host inspection: display_config\nDisplay config inspection is Windows-only.".into())
14980}
14981
14982// ── inspect_ntp ───────────────────────────────────────────────────────────────
14983
14984#[cfg(windows)]
14985fn inspect_ntp() -> Result<String, String> {
14986    let mut out = String::new();
14987
14988    // w32tm status
14989    out.push_str("=== Windows Time service ===\n");
14990    let ps_svc = r#"
14991$svc = Get-Service W32Time -ErrorAction SilentlyContinue
14992if ($svc) { "W32Time | Status: $($svc.Status) | StartType: $($svc.StartType)" }
14993else { "W32Time service not found" }
14994"#;
14995    match run_powershell(ps_svc) {
14996        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
14997        Err(_) => out.push_str("- Could not query W32Time service\n"),
14998    }
14999
15000    // NTP source and last sync
15001    out.push_str("\n=== NTP source and sync status ===\n");
15002    let ps_sync = r#"
15003$q = w32tm /query /status 2>$null
15004if ($q) { $q } else { "w32tm query unavailable" }
15005"#;
15006    match run_powershell(ps_sync) {
15007        Ok(o) if !o.trim().is_empty() => {
15008            for line in o.lines() {
15009                let l = line.trim();
15010                if !l.is_empty() {
15011                    out.push_str(&format!("  {l}\n"));
15012                }
15013            }
15014        }
15015        _ => out.push_str("  - Could not query w32tm status\n"),
15016    }
15017
15018    // Configured NTP server
15019    out.push_str("\n=== Configured NTP servers ===\n");
15020    let ps_peers = r#"
15021w32tm /query /peers 2>$null | Select-Object -First 10
15022"#;
15023    match run_powershell(ps_peers) {
15024        Ok(o) if !o.trim().is_empty() => {
15025            for line in o.lines() {
15026                let l = line.trim();
15027                if !l.is_empty() {
15028                    out.push_str(&format!("  {l}\n"));
15029                }
15030            }
15031        }
15032        _ => {
15033            // Fallback: registry
15034            let ps_reg = r#"
15035(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\W32Time\Parameters' -Name NtpServer -ErrorAction SilentlyContinue).NtpServer
15036"#;
15037            match run_powershell(ps_reg) {
15038                Ok(o) if !o.trim().is_empty() => {
15039                    out.push_str(&format!("  NtpServer (registry): {}\n", o.trim()));
15040                }
15041                _ => out.push_str("  - Could not enumerate NTP peers\n"),
15042            }
15043        }
15044    }
15045
15046    let mut findings: Vec<String> = Vec::new();
15047    if out.contains("W32Time | Status: Stopped") {
15048        findings.push("Windows Time service is stopped — system clock will drift and may cause authentication or certificate failures. Start with: `Start-Service W32Time`.".into());
15049    }
15050    if out.contains("The computer did not resync") || out.contains("Error") {
15051        findings.push("w32tm reports a sync error — check NTP server reachability or run `w32tm /resync /force`.".into());
15052    }
15053
15054    let mut result = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15055    if findings.is_empty() {
15056        result.push_str("- Windows Time service and NTP sync appear healthy.\n");
15057    } else {
15058        for f in &findings {
15059            result.push_str(&format!("- Finding: {f}\n"));
15060        }
15061    }
15062    result.push('\n');
15063    result.push_str(&out);
15064    Ok(result)
15065}
15066
15067#[cfg(not(windows))]
15068fn inspect_ntp() -> Result<String, String> {
15069    // Linux/macOS: check timedatectl / chrony / ntpq
15070    let mut out = String::from("Host inspection: ntp\n\n=== Findings ===\n");
15071
15072    let timedatectl = std::process::Command::new("timedatectl")
15073        .arg("status")
15074        .output();
15075
15076    if let Ok(o) = timedatectl {
15077        let text = String::from_utf8_lossy(&o.stdout);
15078        if text.contains("synchronized: yes") || text.contains("NTP synchronized: yes") {
15079            out.push_str("- NTP synchronized: yes\n\n=== timedatectl status ===\n");
15080        } else {
15081            out.push_str("- Finding: NTP not synchronized — run `timedatectl set-ntp true`\n\n=== timedatectl status ===\n");
15082        }
15083        for line in text.lines() {
15084            let l = line.trim();
15085            if !l.is_empty() {
15086                out.push_str(&format!("  {l}\n"));
15087            }
15088        }
15089        return Ok(out);
15090    }
15091
15092    // macOS fallback
15093    let sntp = std::process::Command::new("sntp")
15094        .args(["-d", "time.apple.com"])
15095        .output();
15096    if let Ok(o) = sntp {
15097        out.push_str("- NTP check via sntp:\n");
15098        out.push_str(&String::from_utf8_lossy(&o.stdout));
15099        return Ok(out);
15100    }
15101
15102    out.push_str("- NTP status unavailable (no timedatectl or sntp found)\n");
15103    Ok(out)
15104}
15105
15106// ── inspect_cpu_power ─────────────────────────────────────────────────────────
15107
15108#[cfg(windows)]
15109fn inspect_cpu_power() -> Result<String, String> {
15110    let mut out = String::new();
15111
15112    // Active power plan
15113    out.push_str("=== Active power plan ===\n");
15114    let ps_plan = r#"
15115$plan = powercfg /getactivescheme 2>$null
15116if ($plan) { $plan } else { "Could not query power scheme" }
15117"#;
15118    match run_powershell(ps_plan) {
15119        Ok(o) if !o.trim().is_empty() => out.push_str(&format!("- {}\n", o.trim())),
15120        _ => out.push_str("- Could not read active power plan\n"),
15121    }
15122
15123    // Processor min/max state and boost policy
15124    out.push_str("\n=== Processor performance policy ===\n");
15125    let ps_proc = r#"
15126$active = (powercfg /getactivescheme) -replace '.*GUID: ([a-f0-9-]+).*','$1'
15127$min = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMIN 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15128$max = powercfg /query $active SUB_PROCESSOR PROCTHROTTLEMAX 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15129$boost = powercfg /query $active SUB_PROCESSOR PERFBOOSTMODE 2>$null | Where-Object { $_ -match 'Current AC Power Setting Index' }
15130if ($min)   { "Min processor state:  $(([Convert]::ToInt32(($min -split '0x')[1],16)))%" }
15131if ($max)   { "Max processor state:  $(([Convert]::ToInt32(($max -split '0x')[1],16)))%" }
15132if ($boost) {
15133    $bval = [Convert]::ToInt32(($boost -split '0x')[1],16)
15134    $bname = switch ($bval) { 0{'Disabled'} 1{'Enabled'} 2{'Aggressive'} 3{'Efficient Enabled'} 4{'Efficient Aggressive'} default{"Unknown ($bval)"} }
15135    "Turbo boost mode:     $bname"
15136}
15137"#;
15138    match run_powershell(ps_proc) {
15139        Ok(o) if !o.trim().is_empty() => {
15140            for line in o.lines() {
15141                let l = line.trim();
15142                if !l.is_empty() {
15143                    out.push_str(&format!("- {l}\n"));
15144                }
15145            }
15146        }
15147        _ => out.push_str("- Could not query processor performance settings\n"),
15148    }
15149
15150    // Current CPU frequency via WMI
15151    out.push_str("\n=== CPU frequency ===\n");
15152    let ps_freq = r#"
15153Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 4 |
15154ForEach-Object {
15155    $cur = $_.CurrentClockSpeed
15156    $max = $_.MaxClockSpeed
15157    $load = $_.LoadPercentage
15158    "$($_.Name.Trim()) | Current: ${cur} MHz | Max: ${max} MHz | Load: ${load}%"
15159}
15160"#;
15161    match run_powershell(ps_freq) {
15162        Ok(o) if !o.trim().is_empty() => {
15163            for line in o.lines() {
15164                let l = line.trim();
15165                if !l.is_empty() {
15166                    out.push_str(&format!("- {l}\n"));
15167                }
15168            }
15169        }
15170        _ => out.push_str("- Could not query CPU frequency via WMI\n"),
15171    }
15172
15173    // Throttle reason from ETW (quick check)
15174    out.push_str("\n=== Throttling indicators ===\n");
15175    let ps_throttle = r#"
15176$pwr = Get-CimInstance -Namespace root\wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction SilentlyContinue
15177if ($pwr) {
15178    $pwr | Select-Object -First 4 | ForEach-Object {
15179        $c = [Math]::Round(($_.CurrentTemperature / 10.0) - 273.15, 1)
15180        "Thermal zone $($_.InstanceName): ${c}°C"
15181    }
15182} else { "Thermal zone WMI not available (normal on consumer hardware)" }
15183"#;
15184    match run_powershell(ps_throttle) {
15185        Ok(o) if !o.trim().is_empty() => {
15186            for line in o.lines() {
15187                let l = line.trim();
15188                if !l.is_empty() {
15189                    out.push_str(&format!("- {l}\n"));
15190                }
15191            }
15192        }
15193        _ => out.push_str("- Thermal zone info unavailable\n"),
15194    }
15195
15196    let mut findings: Vec<String> = Vec::new();
15197    if out.contains("Max processor state:  0%") || out.contains("Max processor state:  1%") {
15198        findings.push("Max processor state is near 0% — CPU is being hard-capped by the power plan. Check power plan settings.".into());
15199    }
15200    if out.contains("Turbo boost mode:     Disabled") {
15201        findings.push("Turbo Boost is disabled in the active power plan — CPU cannot exceed base clock speed.".into());
15202    }
15203    if out.contains("Min processor state:  100%") {
15204        findings.push("Min processor state is 100% — CPU is pinned at max clock. Good for performance, increases power/heat.".into());
15205    }
15206
15207    let mut result = String::from("Host inspection: cpu_power\n\n=== Findings ===\n");
15208    if findings.is_empty() {
15209        result.push_str("- CPU power and frequency settings appear normal.\n");
15210    } else {
15211        for f in &findings {
15212            result.push_str(&format!("- Finding: {f}\n"));
15213        }
15214    }
15215    result.push('\n');
15216    result.push_str(&out);
15217    Ok(result)
15218}
15219
15220#[cfg(windows)]
15221fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15222    let mut out = String::new();
15223
15224    out.push_str("=== Credential vault summary ===\n");
15225    let ps_summary = r#"
15226$raw = cmdkey /list 2>&1
15227$lines = $raw -split "`n"
15228$total = ($lines | Where-Object { $_ -match "Target:" }).Count
15229"Total stored credentials: $total"
15230$windows = ($lines | Where-Object { $_ -match "Type: Windows" }).Count
15231$generic = ($lines | Where-Object { $_ -match "Type: Generic" }).Count
15232$cert    = ($lines | Where-Object { $_ -match "Type: Certificate" }).Count
15233"  Windows credentials: $windows"
15234"  Generic credentials: $generic"
15235"  Certificate-based:   $cert"
15236"#;
15237    match run_powershell(ps_summary) {
15238        Ok(o) => {
15239            for line in o.lines() {
15240                let l = line.trim();
15241                if !l.is_empty() {
15242                    out.push_str(&format!("- {l}\n"));
15243                }
15244            }
15245        }
15246        Err(e) => out.push_str(&format!("- Credential summary error: {e}\n")),
15247    }
15248
15249    out.push_str("\n=== Credential targets (up to 20) ===\n");
15250    let ps_list = r#"
15251$raw = cmdkey /list 2>&1
15252$entries = @(); $cur = @{}
15253foreach ($line in ($raw -split "`n")) {
15254    $l = $line.Trim()
15255    if     ($l -match "^Target:\s*(.+)")  { $cur = @{ Target=$Matches[1] } }
15256    elseif ($l -match "^Type:\s*(.+)"   -and $cur.Target) { $cur.Type=$Matches[1] }
15257    elseif ($l -match "^User:\s*(.+)"   -and $cur.Target) { $cur.User=$Matches[1]; $entries+=$cur; $cur=@{} }
15258}
15259$entries | Select-Object -Last 20 | ForEach-Object {
15260    "[$($_.Type)] $($_.Target)  (user: $($_.User))"
15261}
15262"#;
15263    match run_powershell(ps_list) {
15264        Ok(o) => {
15265            let lines: Vec<&str> = o
15266                .lines()
15267                .map(|l| l.trim())
15268                .filter(|l| !l.is_empty())
15269                .collect();
15270            if lines.is_empty() {
15271                out.push_str("- No credential entries found\n");
15272            } else {
15273                for l in &lines {
15274                    out.push_str(&format!("- {l}\n"));
15275                }
15276            }
15277        }
15278        Err(e) => out.push_str(&format!("- Credential list error: {e}\n")),
15279    }
15280
15281    let total_creds: usize = {
15282        let ps_count = r#"(cmdkey /list 2>&1 | Select-String "Target:").Count"#;
15283        run_powershell(ps_count)
15284            .ok()
15285            .and_then(|s| s.trim().parse().ok())
15286            .unwrap_or(0)
15287    };
15288
15289    let mut findings: Vec<String> = Vec::new();
15290    if total_creds > 30 {
15291        findings.push(format!(
15292            "{total_creds} stored credentials found — consider auditing for stale entries."
15293        ));
15294    }
15295
15296    let mut result = String::from("Host inspection: credentials\n\n=== Findings ===\n");
15297    if findings.is_empty() {
15298        result.push_str("- Credential store looks normal.\n");
15299    } else {
15300        for f in &findings {
15301            result.push_str(&format!("- Finding: {f}\n"));
15302        }
15303    }
15304    result.push('\n');
15305    result.push_str(&out);
15306    Ok(result)
15307}
15308
15309#[cfg(not(windows))]
15310fn inspect_credentials(_max_entries: usize) -> Result<String, String> {
15311    Ok("Host inspection: credentials\n\n=== Findings ===\n- Credential Manager is Windows-only. Use `secret-tool` or `pass` on Linux.\n".into())
15312}
15313
15314#[cfg(windows)]
15315fn inspect_tpm() -> Result<String, String> {
15316    let mut out = String::new();
15317
15318    out.push_str("=== TPM state ===\n");
15319    let ps_tpm = r#"
15320function Emit-Field([string]$Name, $Value, [string]$Fallback = "Unknown") {
15321    $text = if ($null -eq $Value) { "" } else { [string]$Value }
15322    if ([string]::IsNullOrWhiteSpace($text)) { $text = $Fallback }
15323    "$Name$text"
15324}
15325$t = Get-Tpm -ErrorAction SilentlyContinue
15326if ($t) {
15327    Emit-Field "TpmPresent:          " $t.TpmPresent
15328    Emit-Field "TpmReady:            " $t.TpmReady
15329    Emit-Field "TpmEnabled:          " $t.TpmEnabled
15330    Emit-Field "TpmOwned:            " $t.TpmOwned
15331    Emit-Field "RestartPending:      " $t.RestartPending
15332    Emit-Field "ManufacturerIdTxt:   " $t.ManufacturerIdTxt
15333    Emit-Field "ManufacturerVersion: " $t.ManufacturerVersion
15334} else { "TPM module unavailable" }
15335"#;
15336    match run_powershell(ps_tpm) {
15337        Ok(o) => {
15338            for line in o.lines() {
15339                let l = line.trim();
15340                if !l.is_empty() {
15341                    out.push_str(&format!("- {l}\n"));
15342                }
15343            }
15344        }
15345        Err(e) => out.push_str(&format!("- Get-Tpm error: {e}\n")),
15346    }
15347
15348    out.push_str("\n=== TPM spec version (WMI) ===\n");
15349    let ps_spec = r#"
15350$wmi = Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm -ErrorAction SilentlyContinue
15351if ($wmi) {
15352    $spec = if ([string]::IsNullOrWhiteSpace([string]$wmi.SpecVersion)) { "Unknown" } else { [string]$wmi.SpecVersion }
15353    "SpecVersion:  $spec"
15354    "IsActivated:  $(if ($null -eq $wmi.IsActivated_InitialValue) { 'Unknown' } else { $wmi.IsActivated_InitialValue })"
15355    "IsEnabled:    $(if ($null -eq $wmi.IsEnabled_InitialValue) { 'Unknown' } else { $wmi.IsEnabled_InitialValue })"
15356    "IsOwned:      $(if ($null -eq $wmi.IsOwned_InitialValue) { 'Unknown' } else { $wmi.IsOwned_InitialValue })"
15357} else { "Win32_Tpm WMI class unavailable (may need elevation or no TPM)" }
15358"#;
15359    match run_powershell(ps_spec) {
15360        Ok(o) => {
15361            for line in o.lines() {
15362                let l = line.trim();
15363                if !l.is_empty() {
15364                    out.push_str(&format!("- {l}\n"));
15365                }
15366            }
15367        }
15368        Err(e) => out.push_str(&format!("- Win32_Tpm WMI error: {e}\n")),
15369    }
15370
15371    out.push_str("\n=== Secure Boot state ===\n");
15372    let ps_sb = r#"
15373try {
15374    $sb = Confirm-SecureBootUEFI -ErrorAction Stop
15375    if ($sb) { "Secure Boot: ENABLED" } else { "Secure Boot: DISABLED" }
15376} catch {
15377    $msg = $_.Exception.Message
15378    if ($msg -match "Access was denied" -or $msg -match "proper privileges") {
15379        "Secure Boot: Unknown (administrator privileges required)"
15380    } elseif ($msg -match "Cmdlet not supported on this platform") {
15381        "Secure Boot: N/A (Legacy BIOS or unsupported firmware)"
15382    } else {
15383        "Secure Boot: N/A ($msg)"
15384    }
15385}
15386"#;
15387    match run_powershell(ps_sb) {
15388        Ok(o) => {
15389            for line in o.lines() {
15390                let l = line.trim();
15391                if !l.is_empty() {
15392                    out.push_str(&format!("- {l}\n"));
15393                }
15394            }
15395        }
15396        Err(e) => out.push_str(&format!("- Secure Boot check error: {e}\n")),
15397    }
15398
15399    out.push_str("\n=== Firmware type ===\n");
15400    let ps_fw = r#"
15401$fw = (Get-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control -Name "PEFirmwareType" -ErrorAction SilentlyContinue).PEFirmwareType
15402switch ($fw) {
15403    1 { "Firmware type: BIOS (Legacy)" }
15404    2 { "Firmware type: UEFI" }
15405    default {
15406        $bcd = bcdedit /enum firmware 2>$null
15407        if ($LASTEXITCODE -eq 0 -and $bcd) { "Firmware type: UEFI (bcdedit fallback)" }
15408        else { "Firmware type: Unknown or not set" }
15409    }
15410}
15411"#;
15412    match run_powershell(ps_fw) {
15413        Ok(o) => {
15414            for line in o.lines() {
15415                let l = line.trim();
15416                if !l.is_empty() {
15417                    out.push_str(&format!("- {l}\n"));
15418                }
15419            }
15420        }
15421        Err(e) => out.push_str(&format!("- Firmware type error: {e}\n")),
15422    }
15423
15424    let mut findings: Vec<String> = Vec::new();
15425    let mut indeterminate = false;
15426    if out.contains("TpmPresent:          False") {
15427        findings.push("No TPM detected — BitLocker hardware encryption and Windows 11 security features unavailable.".into());
15428    }
15429    if out.contains("TpmReady:            False") {
15430        findings.push(
15431            "TPM present but not ready — may need initialization in BIOS/UEFI settings.".into(),
15432        );
15433    }
15434    if out.contains("SpecVersion:  1.2") {
15435        findings.push("TPM 1.2 detected — Windows 11 requires TPM 2.0.".into());
15436    }
15437    if out.contains("Secure Boot: DISABLED") {
15438        findings.push("Secure Boot is disabled — recommended to enable in UEFI firmware for Windows 11 compliance.".into());
15439    }
15440    if out.contains("Firmware type: BIOS (Legacy)") {
15441        findings.push(
15442            "Legacy BIOS detected — Secure Boot and modern TPM require UEFI firmware.".into(),
15443        );
15444    }
15445
15446    if out.contains("TPM module unavailable")
15447        || out.contains("Win32_Tpm WMI class unavailable")
15448        || out.contains("Secure Boot: N/A")
15449        || out.contains("Secure Boot: Unknown")
15450        || out.contains("Firmware type: Unknown or not set")
15451        || out.contains("TpmPresent:          Unknown")
15452        || out.contains("TpmReady:            Unknown")
15453        || out.contains("TpmEnabled:          Unknown")
15454    {
15455        indeterminate = true;
15456    }
15457    if indeterminate {
15458        findings.push(
15459            "TPM / Secure Boot state could not be fully determined from this session - firmware mode, privileges, or Windows TPM providers may be limiting visibility."
15460                .into(),
15461        );
15462    }
15463
15464    let mut result = String::from("Host inspection: tpm\n\n=== Findings ===\n");
15465    if findings.is_empty() {
15466        result.push_str("- TPM and Secure Boot appear healthy.\n");
15467    } else {
15468        for f in &findings {
15469            result.push_str(&format!("- Finding: {f}\n"));
15470        }
15471    }
15472    result.push('\n');
15473    result.push_str(&out);
15474    Ok(result)
15475}
15476
15477#[cfg(not(windows))]
15478fn inspect_tpm() -> Result<String, String> {
15479    Ok(
15480        "Host inspection: tpm\n\n=== Findings ===\n- TPM/Secure Boot inspection is Windows-only.\n"
15481            .into(),
15482    )
15483}
15484
15485#[cfg(windows)]
15486fn inspect_latency() -> Result<String, String> {
15487    let mut out = String::new();
15488
15489    // Resolve default gateway from the routing table
15490    let ps_gw = r#"
15491$gw = (Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
15492       Sort-Object RouteMetric | Select-Object -First 1).NextHop
15493if ($gw) { $gw } else { "" }
15494"#;
15495    let gateway = run_powershell(ps_gw)
15496        .ok()
15497        .map(|s| s.trim().to_string())
15498        .filter(|s| !s.is_empty());
15499
15500    let targets: Vec<(&str, String)> = {
15501        let mut t = Vec::new();
15502        if let Some(ref gw) = gateway {
15503            t.push(("Default gateway", gw.clone()));
15504        }
15505        t.push(("Cloudflare DNS", "1.1.1.1".into()));
15506        t.push(("Google DNS", "8.8.8.8".into()));
15507        t
15508    };
15509
15510    let mut findings: Vec<String> = Vec::new();
15511
15512    for (label, host) in &targets {
15513        out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
15514        // Test-NetConnection gives RTT; -InformationLevel Quiet just returns bool, so use ping
15515        let ps_ping = format!(
15516            r#"
15517$r = Test-Connection -ComputerName "{host}" -Count 4 -ErrorAction SilentlyContinue
15518if ($r) {{
15519    $rtts = $r | ForEach-Object {{ $_.ResponseTime }}
15520    $min  = ($rtts | Measure-Object -Minimum).Minimum
15521    $max  = ($rtts | Measure-Object -Maximum).Maximum
15522    $avg  = [Math]::Round(($rtts | Measure-Object -Average).Average, 1)
15523    $loss = [Math]::Round((4 - $r.Count) / 4 * 100)
15524    "RTT min/avg/max: ${{min}}ms / ${{avg}}ms / ${{max}}ms"
15525    "Packet loss: ${{loss}}%"
15526    "Sent: 4  Received: $($r.Count)"
15527}} else {{
15528    "UNREACHABLE — 100% packet loss"
15529}}
15530"#
15531        );
15532        match run_powershell(&ps_ping) {
15533            Ok(o) => {
15534                let body = o.trim().to_string();
15535                for line in body.lines() {
15536                    let l = line.trim();
15537                    if !l.is_empty() {
15538                        out.push_str(&format!("- {l}\n"));
15539                    }
15540                }
15541                if body.contains("UNREACHABLE") {
15542                    findings.push(format!(
15543                        "{label} ({host}) is unreachable — possible routing or firewall issue."
15544                    ));
15545                } else if let Some(loss_line) = body.lines().find(|l| l.contains("Packet loss")) {
15546                    let pct: u32 = loss_line
15547                        .chars()
15548                        .filter(|c| c.is_ascii_digit())
15549                        .collect::<String>()
15550                        .parse()
15551                        .unwrap_or(0);
15552                    if pct >= 25 {
15553                        findings.push(format!("{label} ({host}): {pct}% packet loss detected — possible network instability."));
15554                    }
15555                    // High latency check
15556                    if let Some(rtt_line) = body.lines().find(|l| l.contains("RTT min/avg/max")) {
15557                        // parse avg from "RTT min/avg/max: Xms / Yms / Zms"
15558                        let parts: Vec<&str> = rtt_line.split('/').collect();
15559                        if parts.len() >= 2 {
15560                            let avg_str: String =
15561                                parts[1].chars().filter(|c| c.is_ascii_digit()).collect();
15562                            let avg: u32 = avg_str.parse().unwrap_or(0);
15563                            if avg > 150 {
15564                                findings.push(format!("{label} ({host}): high average RTT ({avg}ms) — check for congestion or routing issues."));
15565                            }
15566                        }
15567                    }
15568                }
15569            }
15570            Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
15571        }
15572    }
15573
15574    let mut result = String::from("Host inspection: latency\n\n=== Findings ===\n");
15575    if findings.is_empty() {
15576        result.push_str("- Latency and reachability look normal.\n");
15577    } else {
15578        for f in &findings {
15579            result.push_str(&format!("- Finding: {f}\n"));
15580        }
15581    }
15582    result.push('\n');
15583    result.push_str(&out);
15584    Ok(result)
15585}
15586
15587#[cfg(not(windows))]
15588fn inspect_latency() -> Result<String, String> {
15589    let mut out = String::from("Host inspection: latency\n\n=== Findings ===\n");
15590    let targets = [("Cloudflare DNS", "1.1.1.1"), ("Google DNS", "8.8.8.8")];
15591    let mut findings: Vec<String> = Vec::new();
15592
15593    for (label, host) in &targets {
15594        out.push_str(&format!("\n=== Ping: {label} ({host}) ===\n"));
15595        let ping = std::process::Command::new("ping")
15596            .args(["-c", "4", "-W", "2", host])
15597            .output();
15598        match ping {
15599            Ok(o) => {
15600                let body = String::from_utf8_lossy(&o.stdout).into_owned();
15601                for line in body.lines() {
15602                    let l = line.trim();
15603                    if l.contains("ms") || l.contains("loss") || l.contains("transmitted") {
15604                        out.push_str(&format!("- {l}\n"));
15605                    }
15606                }
15607                if body.contains("100% packet loss") || body.contains("100.0% packet loss") {
15608                    findings.push(format!("{label} ({host}) is unreachable."));
15609                }
15610            }
15611            Err(e) => out.push_str(&format!("- ping error: {e}\n")),
15612        }
15613    }
15614
15615    if findings.is_empty() {
15616        out.insert_str(
15617            "Host inspection: latency\n\n=== Findings ===\n".len(),
15618            "- Latency and reachability look normal.\n",
15619        );
15620    } else {
15621        let mut prefix = String::new();
15622        for f in &findings {
15623            prefix.push_str(&format!("- Finding: {f}\n"));
15624        }
15625        out.insert_str(
15626            "Host inspection: latency\n\n=== Findings ===\n".len(),
15627            &prefix,
15628        );
15629    }
15630    Ok(out)
15631}
15632
15633#[cfg(windows)]
15634fn inspect_network_adapter() -> Result<String, String> {
15635    let mut out = String::new();
15636
15637    out.push_str("=== Network adapters ===\n");
15638    let ps_adapters = r#"
15639Get-NetAdapter | Sort-Object Status,Name | ForEach-Object {
15640    $speed = if ($_.LinkSpeed) { $_.LinkSpeed } else { "Unknown" }
15641    "$($_.Name) | Status: $($_.Status) | Speed: $speed | MAC: $($_.MacAddress) | Driver: $($_.DriverVersion)"
15642}
15643"#;
15644    match run_powershell(ps_adapters) {
15645        Ok(o) => {
15646            for line in o.lines() {
15647                let l = line.trim();
15648                if !l.is_empty() {
15649                    out.push_str(&format!("- {l}\n"));
15650                }
15651            }
15652        }
15653        Err(e) => out.push_str(&format!("- Adapter query error: {e}\n")),
15654    }
15655
15656    out.push_str("\n=== Offload and performance settings (Up adapters) ===\n");
15657    let ps_offload = r#"
15658Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
15659    $name = $_.Name
15660    $props = Get-NetAdapterAdvancedProperty -Name $name -ErrorAction SilentlyContinue |
15661        Where-Object { $_.DisplayName -match "Offload|RSS|Jumbo|Buffer|Flow|Interrupt|Checksum|Large Send" } |
15662        Select-Object DisplayName, DisplayValue
15663    if ($props) {
15664        "--- $name ---"
15665        $props | ForEach-Object { "  $($_.DisplayName): $($_.DisplayValue)" }
15666    }
15667}
15668"#;
15669    match run_powershell(ps_offload) {
15670        Ok(o) => {
15671            let lines: Vec<&str> = o
15672                .lines()
15673                .map(|l| l.trim())
15674                .filter(|l| !l.is_empty())
15675                .collect();
15676            if lines.is_empty() {
15677                out.push_str(
15678                    "- No offload settings exposed by driver (common on virtual/Wi-Fi adapters)\n",
15679                );
15680            } else {
15681                for l in &lines {
15682                    out.push_str(&format!("- {l}\n"));
15683                }
15684            }
15685        }
15686        Err(e) => out.push_str(&format!("- Offload query error: {e}\n")),
15687    }
15688
15689    out.push_str("\n=== Adapter error counters ===\n");
15690    let ps_errors = r#"
15691Get-NetAdapterStatistics | ForEach-Object {
15692    $errs = $_.ReceivedPacketErrors + $_.OutboundPacketErrors + $_.ReceivedDiscardedPackets + $_.OutboundDiscardedPackets
15693    if ($errs -gt 0) {
15694        "$($_.Name) | RX errors: $($_.ReceivedPacketErrors) | TX errors: $($_.OutboundPacketErrors) | RX discards: $($_.ReceivedDiscardedPackets) | TX discards: $($_.OutboundDiscardedPackets)"
15695    }
15696}
15697"#;
15698    match run_powershell(ps_errors) {
15699        Ok(o) => {
15700            let lines: Vec<&str> = o
15701                .lines()
15702                .map(|l| l.trim())
15703                .filter(|l| !l.is_empty())
15704                .collect();
15705            if lines.is_empty() {
15706                out.push_str("- No adapter errors or discards detected.\n");
15707            } else {
15708                for l in &lines {
15709                    out.push_str(&format!("- {l}\n"));
15710                }
15711            }
15712        }
15713        Err(e) => out.push_str(&format!("- Error counter query: {e}\n")),
15714    }
15715
15716    out.push_str("\n=== Wake-on-LAN and power settings ===\n");
15717    let ps_wol = r#"
15718Get-NetAdapter | Where-Object Status -eq "Up" | ForEach-Object {
15719    $wol = Get-NetAdapterPowerManagement -Name $_.Name -ErrorAction SilentlyContinue
15720    if ($wol) {
15721        "$($_.Name) | WakeOnMagicPacket: $($wol.WakeOnMagicPacket) | AllowComputerToTurnOffDevice: $($wol.AllowComputerToTurnOffDevice)"
15722    }
15723}
15724"#;
15725    match run_powershell(ps_wol) {
15726        Ok(o) => {
15727            let lines: Vec<&str> = o
15728                .lines()
15729                .map(|l| l.trim())
15730                .filter(|l| !l.is_empty())
15731                .collect();
15732            if lines.is_empty() {
15733                out.push_str("- Power management data unavailable for active adapters.\n");
15734            } else {
15735                for l in &lines {
15736                    out.push_str(&format!("- {l}\n"));
15737                }
15738            }
15739        }
15740        Err(e) => out.push_str(&format!("- WoL query error: {e}\n")),
15741    }
15742
15743    let mut findings: Vec<String> = Vec::new();
15744    // Check for error-prone adapters
15745    if out.contains("RX errors:") || out.contains("TX errors:") {
15746        findings
15747            .push("Adapter errors detected — check cabling, driver, or duplex mismatch.".into());
15748    }
15749    // Check for half-duplex (rare but still seen on older switches)
15750    if out.contains("Half") {
15751        findings.push("Half-duplex adapter detected — likely a duplex mismatch with the switch; set both sides to full-duplex.".into());
15752    }
15753
15754    let mut result = String::from("Host inspection: network_adapter\n\n=== Findings ===\n");
15755    if findings.is_empty() {
15756        result.push_str("- Network adapter configuration looks normal.\n");
15757    } else {
15758        for f in &findings {
15759            result.push_str(&format!("- Finding: {f}\n"));
15760        }
15761    }
15762    result.push('\n');
15763    result.push_str(&out);
15764    Ok(result)
15765}
15766
15767#[cfg(not(windows))]
15768fn inspect_network_adapter() -> Result<String, String> {
15769    let mut out = String::from("Host inspection: network_adapter\n\n=== Findings ===\n- Network adapter inspection running on Unix.\n\n");
15770
15771    out.push_str("=== Network adapters (ip link) ===\n");
15772    let ip_link = std::process::Command::new("ip")
15773        .args(["link", "show"])
15774        .output();
15775    if let Ok(o) = ip_link {
15776        for line in String::from_utf8_lossy(&o.stdout).lines() {
15777            let l = line.trim();
15778            if !l.is_empty() {
15779                out.push_str(&format!("- {l}\n"));
15780            }
15781        }
15782    }
15783
15784    out.push_str("\n=== Adapter statistics (ip -s link) ===\n");
15785    let ip_stats = std::process::Command::new("ip")
15786        .args(["-s", "link", "show"])
15787        .output();
15788    if let Ok(o) = ip_stats {
15789        for line in String::from_utf8_lossy(&o.stdout).lines() {
15790            let l = line.trim();
15791            if l.contains("RX") || l.contains("TX") || l.contains("errors") || l.contains("dropped")
15792            {
15793                out.push_str(&format!("- {l}\n"));
15794            }
15795        }
15796    }
15797    Ok(out)
15798}
15799
15800#[cfg(windows)]
15801fn inspect_dhcp() -> Result<String, String> {
15802    let mut out = String::new();
15803
15804    out.push_str("=== DHCP lease details (per adapter) ===\n");
15805    let ps_dhcp = r#"
15806$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
15807    Where-Object { $_.IPEnabled -eq $true }
15808foreach ($a in $adapters) {
15809    "--- $($a.Description) ---"
15810    "  DHCP Enabled:      $($a.DHCPEnabled)"
15811    if ($a.DHCPEnabled) {
15812        "  DHCP Server:       $($a.DHCPServer)"
15813        $obtained = $a.ConvertToDateTime($a.DHCPLeaseObtained) 2>$null
15814        $expires  = $a.ConvertToDateTime($a.DHCPLeaseExpires)  2>$null
15815        "  Lease Obtained:    $obtained"
15816        "  Lease Expires:     $expires"
15817    }
15818    "  IP Address:        $($a.IPAddress -join ', ')"
15819    "  Subnet Mask:       $($a.IPSubnet -join ', ')"
15820    "  Default Gateway:   $($a.DefaultIPGateway -join ', ')"
15821    "  DNS Servers:       $($a.DNSServerSearchOrder -join ', ')"
15822    "  MAC Address:       $($a.MACAddress)"
15823    ""
15824}
15825"#;
15826    match run_powershell(ps_dhcp) {
15827        Ok(o) => {
15828            for line in o.lines() {
15829                let l = line.trim_end();
15830                if !l.is_empty() {
15831                    out.push_str(&format!("{l}\n"));
15832                }
15833            }
15834        }
15835        Err(e) => out.push_str(&format!("- DHCP query error: {e}\n")),
15836    }
15837
15838    // Findings: check for expired or very-soon-expiring leases
15839    let mut findings: Vec<String> = Vec::new();
15840    let ps_expiry = r#"
15841$adapters = Get-WmiObject Win32_NetworkAdapterConfiguration | Where-Object { $_.DHCPEnabled -and $_.IPEnabled }
15842foreach ($a in $adapters) {
15843    try {
15844        $exp = $a.ConvertToDateTime($a.DHCPLeaseExpires)
15845        $now = Get-Date
15846        $hrs = ($exp - $now).TotalHours
15847        if ($hrs -lt 0) { "$($a.Description): EXPIRED" }
15848        elseif ($hrs -lt 2) { "$($a.Description): expires in $([Math]::Round($hrs,1)) hours" }
15849    } catch {}
15850}
15851"#;
15852    if let Ok(o) = run_powershell(ps_expiry) {
15853        for line in o.lines() {
15854            let l = line.trim();
15855            if !l.is_empty() {
15856                if l.contains("EXPIRED") {
15857                    findings.push(format!("DHCP lease EXPIRED on adapter: {l}"));
15858                } else if l.contains("expires in") {
15859                    findings.push(format!("DHCP lease expiring soon — {l}"));
15860                }
15861            }
15862        }
15863    }
15864
15865    let mut result = String::from("Host inspection: dhcp\n\n=== Findings ===\n");
15866    if findings.is_empty() {
15867        result.push_str("- DHCP leases look healthy.\n");
15868    } else {
15869        for f in &findings {
15870            result.push_str(&format!("- Finding: {f}\n"));
15871        }
15872    }
15873    result.push('\n');
15874    result.push_str(&out);
15875    Ok(result)
15876}
15877
15878#[cfg(not(windows))]
15879fn inspect_dhcp() -> Result<String, String> {
15880    let mut out = String::from(
15881        "Host inspection: dhcp\n\n=== Findings ===\n- DHCP lease inspection running on Unix.\n\n",
15882    );
15883    out.push_str("=== DHCP leases (dhclient / NetworkManager) ===\n");
15884    for path in &["/var/lib/dhcp/dhclient.leases", "/var/lib/NetworkManager"] {
15885        if std::path::Path::new(path).exists() {
15886            let cat = std::process::Command::new("cat").arg(path).output();
15887            if let Ok(o) = cat {
15888                let text = String::from_utf8_lossy(&o.stdout);
15889                for line in text.lines().take(40) {
15890                    let l = line.trim();
15891                    if l.contains("lease")
15892                        || l.contains("expire")
15893                        || l.contains("server")
15894                        || l.contains("address")
15895                    {
15896                        out.push_str(&format!("- {l}\n"));
15897                    }
15898                }
15899            }
15900        }
15901    }
15902    // Also try ip addr for current IPs
15903    let ip = std::process::Command::new("ip")
15904        .args(["addr", "show"])
15905        .output();
15906    if let Ok(o) = ip {
15907        out.push_str("\n=== Current IP addresses (ip addr) ===\n");
15908        for line in String::from_utf8_lossy(&o.stdout).lines() {
15909            let l = line.trim();
15910            if l.starts_with("inet") || l.contains("dynamic") {
15911                out.push_str(&format!("- {l}\n"));
15912            }
15913        }
15914    }
15915    Ok(out)
15916}
15917
15918#[cfg(windows)]
15919fn inspect_mtu() -> Result<String, String> {
15920    let mut out = String::new();
15921
15922    out.push_str("=== Per-adapter MTU (IPv4) ===\n");
15923    let ps_mtu = r#"
15924Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv4" } |
15925    Sort-Object ConnectionState, InterfaceAlias |
15926    ForEach-Object {
15927        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
15928    }
15929"#;
15930    match run_powershell(ps_mtu) {
15931        Ok(o) => {
15932            for line in o.lines() {
15933                let l = line.trim();
15934                if !l.is_empty() {
15935                    out.push_str(&format!("- {l}\n"));
15936                }
15937            }
15938        }
15939        Err(e) => out.push_str(&format!("- MTU query error: {e}\n")),
15940    }
15941
15942    out.push_str("\n=== Per-adapter MTU (IPv6) ===\n");
15943    let ps_mtu6 = r#"
15944Get-NetIPInterface | Where-Object { $_.AddressFamily -eq "IPv6" } |
15945    Sort-Object ConnectionState, InterfaceAlias |
15946    ForEach-Object {
15947        "$($_.InterfaceAlias) | MTU: $($_.NlMtu) bytes | State: $($_.ConnectionState)"
15948    }
15949"#;
15950    match run_powershell(ps_mtu6) {
15951        Ok(o) => {
15952            for line in o.lines() {
15953                let l = line.trim();
15954                if !l.is_empty() {
15955                    out.push_str(&format!("- {l}\n"));
15956                }
15957            }
15958        }
15959        Err(e) => out.push_str(&format!("- IPv6 MTU query error: {e}\n")),
15960    }
15961
15962    out.push_str("\n=== Path MTU discovery (ping DF-bit to 8.8.8.8) ===\n");
15963    // Send a 1472-byte payload (1500 - 28 IP+ICMP headers) to test standard Ethernet MTU
15964    let ps_pmtu = r#"
15965$sizes = @(1472, 1400, 1280, 576)
15966$result = $null
15967foreach ($s in $sizes) {
15968    $r = Test-Connection -ComputerName "8.8.8.8" -Count 1 -BufferSize $s -ErrorAction SilentlyContinue
15969    if ($r) { $result = $s; break }
15970}
15971if ($result) { "Largest successful payload: $result bytes (path MTU >= $($result + 28) bytes)" }
15972else { "All test sizes failed — path MTU may be very restricted or ICMP is blocked" }
15973"#;
15974    match run_powershell(ps_pmtu) {
15975        Ok(o) => {
15976            for line in o.lines() {
15977                let l = line.trim();
15978                if !l.is_empty() {
15979                    out.push_str(&format!("- {l}\n"));
15980                }
15981            }
15982        }
15983        Err(e) => out.push_str(&format!("- Path MTU test error: {e}\n")),
15984    }
15985
15986    let mut findings: Vec<String> = Vec::new();
15987    if out.contains("MTU: 576 bytes") {
15988        findings.push("576-byte MTU detected — severely restricted path, likely a misconfigured VPN or legacy link.".into());
15989    }
15990    if out.contains("MTU: 1280 bytes") && !out.contains("IPv6") {
15991        findings.push(
15992            "1280-byte MTU on an IPv4 interface is unusually low — check VPN or PPPoE config."
15993                .into(),
15994        );
15995    }
15996    if out.contains("All test sizes failed") {
15997        findings.push("Path MTU test failed — ICMP may be blocked by firewall or all tested sizes exceed the path limit.".into());
15998    }
15999
16000    let mut result = String::from("Host inspection: mtu\n\n=== Findings ===\n");
16001    if findings.is_empty() {
16002        result.push_str("- MTU configuration looks normal.\n");
16003    } else {
16004        for f in &findings {
16005            result.push_str(&format!("- Finding: {f}\n"));
16006        }
16007    }
16008    result.push('\n');
16009    result.push_str(&out);
16010    Ok(result)
16011}
16012
16013#[cfg(not(windows))]
16014fn inspect_mtu() -> Result<String, String> {
16015    let mut out = String::from(
16016        "Host inspection: mtu\n\n=== Findings ===\n- MTU inspection running on Unix.\n\n",
16017    );
16018
16019    out.push_str("=== Per-interface MTU (ip link) ===\n");
16020    let ip = std::process::Command::new("ip")
16021        .args(["link", "show"])
16022        .output();
16023    if let Ok(o) = ip {
16024        for line in String::from_utf8_lossy(&o.stdout).lines() {
16025            let l = line.trim();
16026            if l.contains("mtu") || l.starts_with("\\d") {
16027                out.push_str(&format!("- {l}\n"));
16028            }
16029        }
16030    }
16031
16032    out.push_str("\n=== Path MTU test (ping -M do to 8.8.8.8) ===\n");
16033    let ping = std::process::Command::new("ping")
16034        .args(["-c", "1", "-M", "do", "-s", "1472", "8.8.8.8"])
16035        .output();
16036    match ping {
16037        Ok(o) => {
16038            let body = String::from_utf8_lossy(&o.stdout);
16039            for line in body.lines() {
16040                let l = line.trim();
16041                if !l.is_empty() {
16042                    out.push_str(&format!("- {l}\n"));
16043                }
16044            }
16045        }
16046        Err(e) => out.push_str(&format!("- Ping error: {e}\n")),
16047    }
16048    Ok(out)
16049}
16050
16051#[cfg(not(windows))]
16052fn inspect_cpu_power() -> Result<String, String> {
16053    let mut out = String::from("Host inspection: cpu_power\n\n=== Findings ===\n- CPU power inspection running on Unix.\n\n");
16054
16055    // Linux: cpufreq-info or /sys/devices/system/cpu
16056    out.push_str("=== CPU frequency (Linux) ===\n");
16057    let cat_scaling = std::process::Command::new("cat")
16058        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq")
16059        .output();
16060    if let Ok(o) = cat_scaling {
16061        let khz: u64 = String::from_utf8_lossy(&o.stdout)
16062            .trim()
16063            .parse()
16064            .unwrap_or(0);
16065        if khz > 0 {
16066            out.push_str(&format!("- Current: {} MHz\n", khz / 1000));
16067        }
16068    }
16069    let cat_max = std::process::Command::new("cat")
16070        .arg("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq")
16071        .output();
16072    if let Ok(o) = cat_max {
16073        let khz: u64 = String::from_utf8_lossy(&o.stdout)
16074            .trim()
16075            .parse()
16076            .unwrap_or(0);
16077        if khz > 0 {
16078            out.push_str(&format!("- Max: {} MHz\n", khz / 1000));
16079        }
16080    }
16081    let governor = std::process::Command::new("cat")
16082        .arg("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
16083        .output();
16084    if let Ok(o) = governor {
16085        let g = String::from_utf8_lossy(&o.stdout);
16086        let g = g.trim();
16087        if !g.is_empty() {
16088            out.push_str(&format!("- Governor: {g}\n"));
16089        }
16090    }
16091    Ok(out)
16092}
16093
16094// ── IPv6 ────────────────────────────────────────────────────────────────────
16095
16096#[cfg(windows)]
16097fn inspect_ipv6() -> Result<String, String> {
16098    let script = r#"
16099$result = [System.Text.StringBuilder]::new()
16100
16101# Per-adapter IPv6 addresses
16102$result.AppendLine("=== IPv6 addresses per adapter ===") | Out-Null
16103$adapters = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16104    Where-Object { $_.IPAddress -notmatch '^::1$' } |
16105    Sort-Object InterfaceAlias
16106foreach ($a in $adapters) {
16107    $prefix = $a.PrefixOrigin
16108    $suffix = $a.SuffixOrigin
16109    $scope  = $a.AddressState
16110    $result.AppendLine("  [$($a.InterfaceAlias)] $($a.IPAddress)/$($a.PrefixLength)  origin=$prefix/$suffix  state=$scope") | Out-Null
16111}
16112if (-not $adapters) { $result.AppendLine("  No global/link-local IPv6 addresses found.") | Out-Null }
16113
16114# Default gateway IPv6
16115$result.AppendLine("") | Out-Null
16116$result.AppendLine("=== IPv6 default gateway ===") | Out-Null
16117$gw6 = Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue
16118if ($gw6) {
16119    foreach ($g in $gw6) {
16120        $result.AppendLine("  [$($g.InterfaceAlias)] via $($g.NextHop)  metric=$($g.RouteMetric)") | Out-Null
16121    }
16122} else {
16123    $result.AppendLine("  No IPv6 default gateway configured.") | Out-Null
16124}
16125
16126# DHCPv6 lease info
16127$result.AppendLine("") | Out-Null
16128$result.AppendLine("=== DHCPv6 / prefix delegation ===") | Out-Null
16129$dhcpv6 = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16130    Where-Object { $_.PrefixOrigin -eq 'Dhcp' -or $_.SuffixOrigin -eq 'Dhcp' }
16131if ($dhcpv6) {
16132    foreach ($d in $dhcpv6) {
16133        $result.AppendLine("  [$($d.InterfaceAlias)] $($d.IPAddress) (DHCPv6-assigned)") | Out-Null
16134    }
16135} else {
16136    $result.AppendLine("  No DHCPv6-assigned addresses found (SLAAC or static in use).") | Out-Null
16137}
16138
16139# Privacy extensions
16140$result.AppendLine("") | Out-Null
16141$result.AppendLine("=== Privacy extensions (RFC 4941) ===") | Out-Null
16142try {
16143    $priv = netsh interface ipv6 show privacy
16144    $result.AppendLine(($priv -join "`n")) | Out-Null
16145} catch {
16146    $result.AppendLine("  Could not retrieve privacy extension state.") | Out-Null
16147}
16148
16149# Tunnel adapters
16150$result.AppendLine("") | Out-Null
16151$result.AppendLine("=== Tunnel adapters ===") | Out-Null
16152$tunnels = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceDescription -match 'Teredo|6to4|ISATAP|Tunnel' }
16153if ($tunnels) {
16154    foreach ($t in $tunnels) {
16155        $result.AppendLine("  $($t.Name): $($t.InterfaceDescription)  Status=$($t.Status)") | Out-Null
16156    }
16157} else {
16158    $result.AppendLine("  No Teredo/6to4/ISATAP tunnel adapters found.") | Out-Null
16159}
16160
16161# Findings
16162$findings = [System.Collections.Generic.List[string]]::new()
16163$globalAddrs = Get-NetIPAddress -AddressFamily IPv6 -ErrorAction SilentlyContinue |
16164    Where-Object { $_.IPAddress -match '^2[0-9a-f]{3}:' -or $_.IPAddress -match '^fc|^fd' }
16165if (-not $globalAddrs) { $findings.Add("No global unicast IPv6 address assigned — IPv6 internet access may be unavailable.") }
16166$noGw6 = -not (Get-NetRoute -AddressFamily IPv6 -DestinationPrefix '::/0' -ErrorAction SilentlyContinue)
16167if ($noGw6) { $findings.Add("No IPv6 default gateway — IPv6 routing not active.") }
16168
16169$result.AppendLine("") | Out-Null
16170$result.AppendLine("=== Findings ===") | Out-Null
16171if ($findings.Count -eq 0) {
16172    $result.AppendLine("- IPv6 configuration looks healthy.") | Out-Null
16173} else {
16174    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16175}
16176
16177Write-Output $result.ToString()
16178"#;
16179    let out = run_powershell(script)?;
16180    Ok(format!("Host inspection: ipv6\n\n{out}"))
16181}
16182
16183#[cfg(not(windows))]
16184fn inspect_ipv6() -> Result<String, String> {
16185    let mut out = String::from("Host inspection: ipv6\n\n=== IPv6 addresses (ip -6 addr) ===\n");
16186    if let Ok(o) = std::process::Command::new("ip")
16187        .args(["-6", "addr", "show"])
16188        .output()
16189    {
16190        out.push_str(&String::from_utf8_lossy(&o.stdout));
16191    }
16192    out.push_str("\n=== IPv6 routes (ip -6 route) ===\n");
16193    if let Ok(o) = std::process::Command::new("ip")
16194        .args(["-6", "route"])
16195        .output()
16196    {
16197        out.push_str(&String::from_utf8_lossy(&o.stdout));
16198    }
16199    Ok(out)
16200}
16201
16202// ── TCP Parameters ──────────────────────────────────────────────────────────
16203
16204#[cfg(windows)]
16205fn inspect_tcp_params() -> Result<String, String> {
16206    let script = r#"
16207$result = [System.Text.StringBuilder]::new()
16208
16209# Autotuning and global TCP settings
16210$result.AppendLine("=== TCP global settings (netsh) ===") | Out-Null
16211try {
16212    $global = netsh interface tcp show global
16213    foreach ($line in $global) {
16214        $l = $line.Trim()
16215        if ($l -and $l -notmatch '^---' -and $l -notmatch '^TCP Global') {
16216            $result.AppendLine("  $l") | Out-Null
16217        }
16218    }
16219} catch {
16220    $result.AppendLine("  Could not retrieve TCP global settings.") | Out-Null
16221}
16222
16223# Supplemental params via Get-NetTCPSetting
16224$result.AppendLine("") | Out-Null
16225$result.AppendLine("=== TCP settings profiles ===") | Out-Null
16226try {
16227    $tcpSettings = Get-NetTCPSetting -ErrorAction SilentlyContinue
16228    foreach ($s in $tcpSettings) {
16229        $result.AppendLine("  Profile: $($s.SettingName)") | Out-Null
16230        $result.AppendLine("    CongestionProvider:      $($s.CongestionProvider)") | Out-Null
16231        $result.AppendLine("    InitialCongestionWindow: $($s.InitialCongestionWindowMss) MSS") | Out-Null
16232        $result.AppendLine("    AutoTuningLevelLocal:    $($s.AutoTuningLevelLocal)") | Out-Null
16233        $result.AppendLine("    ScalingHeuristics:       $($s.ScalingHeuristics)") | Out-Null
16234        $result.AppendLine("    DynamicPortRangeStart:   $($s.DynamicPortRangeStartPort)") | Out-Null
16235        $result.AppendLine("    DynamicPortRangeEnd:     $($s.DynamicPortRangeStartPort + $s.DynamicPortRangeNumberOfPorts - 1)") | Out-Null
16236        $result.AppendLine("") | Out-Null
16237    }
16238} catch {
16239    $result.AppendLine("  Get-NetTCPSetting unavailable.") | Out-Null
16240}
16241
16242# Chimney offload state
16243$result.AppendLine("=== TCP Chimney offload ===") | Out-Null
16244try {
16245    $chimney = netsh interface tcp show chimney
16246    $result.AppendLine(($chimney -join "`n  ")) | Out-Null
16247} catch {
16248    $result.AppendLine("  Could not retrieve chimney state.") | Out-Null
16249}
16250
16251# ECN state
16252$result.AppendLine("") | Out-Null
16253$result.AppendLine("=== ECN capability ===") | Out-Null
16254try {
16255    $ecn = netsh interface tcp show ecncapability
16256    $result.AppendLine(($ecn -join "`n  ")) | Out-Null
16257} catch {
16258    $result.AppendLine("  Could not retrieve ECN state.") | Out-Null
16259}
16260
16261# Findings
16262$findings = [System.Collections.Generic.List[string]]::new()
16263try {
16264    $ts = Get-NetTCPSetting -SettingName 'Internet' -ErrorAction SilentlyContinue
16265    if ($ts -and $ts.AutoTuningLevelLocal -eq 'Disabled') {
16266        $findings.Add("TCP autotuning is DISABLED on the Internet profile — may limit throughput on high-latency links.")
16267    }
16268    if ($ts -and $ts.CongestionProvider -ne 'CUBIC' -and $ts.CongestionProvider -ne 'NewReno' -and $ts.CongestionProvider) {
16269        $findings.Add("Non-standard congestion provider: $($ts.CongestionProvider)")
16270    }
16271} catch {}
16272
16273$result.AppendLine("") | Out-Null
16274$result.AppendLine("=== Findings ===") | Out-Null
16275if ($findings.Count -eq 0) {
16276    $result.AppendLine("- TCP parameters look normal.") | Out-Null
16277} else {
16278    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16279}
16280
16281Write-Output $result.ToString()
16282"#;
16283    let out = run_powershell(script)?;
16284    Ok(format!("Host inspection: tcp_params\n\n{out}"))
16285}
16286
16287#[cfg(not(windows))]
16288fn inspect_tcp_params() -> Result<String, String> {
16289    let mut out = String::from("Host inspection: tcp_params\n\n=== TCP kernel parameters ===\n");
16290    for key in &[
16291        "net.ipv4.tcp_congestion_control",
16292        "net.ipv4.tcp_rmem",
16293        "net.ipv4.tcp_wmem",
16294        "net.ipv4.tcp_window_scaling",
16295        "net.ipv4.tcp_ecn",
16296        "net.ipv4.tcp_timestamps",
16297    ] {
16298        if let Ok(o) = std::process::Command::new("sysctl").arg(key).output() {
16299            out.push_str(&format!(
16300                "  {}\n",
16301                String::from_utf8_lossy(&o.stdout).trim()
16302            ));
16303        }
16304    }
16305    Ok(out)
16306}
16307
16308// ── WLAN Profiles ───────────────────────────────────────────────────────────
16309
16310#[cfg(windows)]
16311fn inspect_wlan_profiles() -> Result<String, String> {
16312    let script = r#"
16313$result = [System.Text.StringBuilder]::new()
16314
16315# List all saved profiles
16316$result.AppendLine("=== Saved wireless profiles ===") | Out-Null
16317try {
16318    $profilesRaw = netsh wlan show profiles
16319    $profiles = $profilesRaw | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16320        $_.Matches[0].Groups[1].Value.Trim()
16321    }
16322
16323    if (-not $profiles) {
16324        $result.AppendLine("  No saved wireless profiles found.") | Out-Null
16325    } else {
16326        foreach ($p in $profiles) {
16327            $result.AppendLine("") | Out-Null
16328            $result.AppendLine("  Profile: $p") | Out-Null
16329            # Get detail for each profile
16330            $detail = netsh wlan show profile name="$p" key=clear 2>$null
16331            $auth      = ($detail | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16332            $cipher    = ($detail | Select-String 'Cipher\s*:\s*(.+)') | Select-Object -First 1
16333            $conn      = ($detail | Select-String 'Connection mode\s*:\s*(.+)') | Select-Object -First 1
16334            $autoConn  = ($detail | Select-String 'Connect automatically\s*:\s*(.+)') | Select-Object -First 1
16335            if ($auth)     { $result.AppendLine("    Authentication:    $($auth.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16336            if ($cipher)   { $result.AppendLine("    Cipher:            $($cipher.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16337            if ($conn)     { $result.AppendLine("    Connection mode:   $($conn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16338            if ($autoConn) { $result.AppendLine("    Auto-connect:      $($autoConn.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16339        }
16340    }
16341} catch {
16342    $result.AppendLine("  netsh wlan unavailable (no wireless adapter or WLAN service not running).") | Out-Null
16343}
16344
16345# Currently connected SSID
16346$result.AppendLine("") | Out-Null
16347$result.AppendLine("=== Currently connected ===") | Out-Null
16348try {
16349    $conn = netsh wlan show interfaces
16350    $ssid   = ($conn | Select-String 'SSID\s*:\s*(?!BSSID)(.+)') | Select-Object -First 1
16351    $bssid  = ($conn | Select-String 'BSSID\s*:\s*(.+)') | Select-Object -First 1
16352    $signal = ($conn | Select-String 'Signal\s*:\s*(.+)') | Select-Object -First 1
16353    $radio  = ($conn | Select-String 'Radio type\s*:\s*(.+)') | Select-Object -First 1
16354    if ($ssid)   { $result.AppendLine("  SSID:       $($ssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16355    if ($bssid)  { $result.AppendLine("  BSSID:      $($bssid.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16356    if ($signal) { $result.AppendLine("  Signal:     $($signal.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16357    if ($radio)  { $result.AppendLine("  Radio type: $($radio.Matches[0].Groups[1].Value.Trim())") | Out-Null }
16358    if (-not $ssid) { $result.AppendLine("  Not connected to any wireless network.") | Out-Null }
16359} catch {
16360    $result.AppendLine("  Could not query wireless interface state.") | Out-Null
16361}
16362
16363# Findings
16364$findings = [System.Collections.Generic.List[string]]::new()
16365try {
16366    $allDetail = netsh wlan show profiles 2>$null
16367    $profileNames = $allDetail | Select-String 'All User Profile\s*:\s*(.+)' | ForEach-Object {
16368        $_.Matches[0].Groups[1].Value.Trim()
16369    }
16370    foreach ($pn in $profileNames) {
16371        $det = netsh wlan show profile name="$pn" key=clear 2>$null
16372        $authLine = ($det | Select-String 'Authentication\s*:\s*(.+)') | Select-Object -First 1
16373        if ($authLine) {
16374            $authVal = $authLine.Matches[0].Groups[1].Value.Trim()
16375            if ($authVal -match 'Open|WEP|None') {
16376                $findings.Add("Profile '$pn' uses weak/open authentication: $authVal")
16377            }
16378        }
16379    }
16380} catch {}
16381
16382$result.AppendLine("") | Out-Null
16383$result.AppendLine("=== Findings ===") | Out-Null
16384if ($findings.Count -eq 0) {
16385    $result.AppendLine("- All saved wireless profiles use acceptable authentication.") | Out-Null
16386} else {
16387    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16388}
16389
16390Write-Output $result.ToString()
16391"#;
16392    let out = run_powershell(script)?;
16393    Ok(format!("Host inspection: wlan_profiles\n\n{out}"))
16394}
16395
16396#[cfg(not(windows))]
16397fn inspect_wlan_profiles() -> Result<String, String> {
16398    let mut out =
16399        String::from("Host inspection: wlan_profiles\n\n=== Saved wireless profiles ===\n");
16400    // Try nmcli (NetworkManager)
16401    if let Ok(o) = std::process::Command::new("nmcli")
16402        .args(["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"])
16403        .output()
16404    {
16405        for line in String::from_utf8_lossy(&o.stdout).lines() {
16406            if line.contains("wireless") || line.contains("wifi") {
16407                out.push_str(&format!("  {line}\n"));
16408            }
16409        }
16410    } else {
16411        out.push_str("  nmcli not available.\n");
16412    }
16413    Ok(out)
16414}
16415
16416// ── IPSec ───────────────────────────────────────────────────────────────────
16417
16418#[cfg(windows)]
16419fn inspect_ipsec() -> Result<String, String> {
16420    let script = r#"
16421$result = [System.Text.StringBuilder]::new()
16422
16423# IPSec rules (firewall-integrated)
16424$result.AppendLine("=== IPSec connection security rules ===") | Out-Null
16425try {
16426    $rules = Get-NetIPsecRule -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' }
16427    if ($rules) {
16428        foreach ($r in $rules) {
16429            $result.AppendLine("  [$($r.DisplayName)]") | Out-Null
16430            $result.AppendLine("    Mode:       $($r.Mode)") | Out-Null
16431            $result.AppendLine("    Action:     $($r.Action)") | Out-Null
16432            $result.AppendLine("    InProfile:  $($r.Profile)") | Out-Null
16433        }
16434    } else {
16435        $result.AppendLine("  No enabled IPSec connection security rules found.") | Out-Null
16436    }
16437} catch {
16438    $result.AppendLine("  Get-NetIPsecRule unavailable.") | Out-Null
16439}
16440
16441# Active main-mode SAs
16442$result.AppendLine("") | Out-Null
16443$result.AppendLine("=== Active IPSec main-mode SAs ===") | Out-Null
16444try {
16445    $mmSAs = Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue
16446    if ($mmSAs) {
16447        foreach ($sa in $mmSAs) {
16448            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
16449            $result.AppendLine("    AuthMethod: $($sa.LocalFirstId)  Cipher: $($sa.Cipher)") | Out-Null
16450        }
16451    } else {
16452        $result.AppendLine("  No active main-mode IPSec SAs.") | Out-Null
16453    }
16454} catch {
16455    $result.AppendLine("  Get-NetIPsecMainModeSA unavailable.") | Out-Null
16456}
16457
16458# Active quick-mode SAs
16459$result.AppendLine("") | Out-Null
16460$result.AppendLine("=== Active IPSec quick-mode SAs ===") | Out-Null
16461try {
16462    $qmSAs = Get-NetIPsecQuickModeSA -ErrorAction SilentlyContinue
16463    if ($qmSAs) {
16464        foreach ($sa in $qmSAs) {
16465            $result.AppendLine("  Local: $($sa.LocalAddress)  <-->  Remote: $($sa.RemoteAddress)") | Out-Null
16466            $result.AppendLine("    Encapsulation: $($sa.EncapsulationMode)  Protocol: $($sa.TransportLayerProtocol)") | Out-Null
16467        }
16468    } else {
16469        $result.AppendLine("  No active quick-mode IPSec SAs.") | Out-Null
16470    }
16471} catch {
16472    $result.AppendLine("  Get-NetIPsecQuickModeSA unavailable.") | Out-Null
16473}
16474
16475# IKE service state
16476$result.AppendLine("") | Out-Null
16477$result.AppendLine("=== IKE / IPSec Policy Agent service ===") | Out-Null
16478$ikeAgentSvc = Get-Service -Name 'PolicyAgent' -ErrorAction SilentlyContinue
16479if ($ikeAgentSvc) {
16480    $result.AppendLine("  PolicyAgent (IPSec Policy Agent): $($ikeAgentSvc.Status)") | Out-Null
16481} else {
16482    $result.AppendLine("  PolicyAgent service not found.") | Out-Null
16483}
16484
16485# Findings
16486$findings = [System.Collections.Generic.List[string]]::new()
16487$mmSACount = 0
16488try { $mmSACount = (Get-NetIPsecMainModeSA -ErrorAction SilentlyContinue | Measure-Object).Count } catch {}
16489if ($mmSACount -gt 0) {
16490    $findings.Add("$mmSACount active IPSec main-mode SA(s) — IPSec tunnel is active.")
16491}
16492
16493$result.AppendLine("") | Out-Null
16494$result.AppendLine("=== Findings ===") | Out-Null
16495if ($findings.Count -eq 0) {
16496    $result.AppendLine("- No active IPSec SAs detected (no IPSec tunnel currently established).") | Out-Null
16497} else {
16498    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16499}
16500
16501Write-Output $result.ToString()
16502"#;
16503    let out = run_powershell(script)?;
16504    Ok(format!("Host inspection: ipsec\n\n{out}"))
16505}
16506
16507#[cfg(not(windows))]
16508fn inspect_ipsec() -> Result<String, String> {
16509    let mut out = String::from("Host inspection: ipsec\n\n=== IPSec SAs (ip xfrm state) ===\n");
16510    if let Ok(o) = std::process::Command::new("ip")
16511        .args(["xfrm", "state"])
16512        .output()
16513    {
16514        let body = String::from_utf8_lossy(&o.stdout);
16515        if body.trim().is_empty() {
16516            out.push_str("  No active IPSec SAs.\n");
16517        } else {
16518            out.push_str(&body);
16519        }
16520    }
16521    out.push_str("\n=== IPSec policies (ip xfrm policy) ===\n");
16522    if let Ok(o) = std::process::Command::new("ip")
16523        .args(["xfrm", "policy"])
16524        .output()
16525    {
16526        let body = String::from_utf8_lossy(&o.stdout);
16527        if body.trim().is_empty() {
16528            out.push_str("  No IPSec policies.\n");
16529        } else {
16530            out.push_str(&body);
16531        }
16532    }
16533    Ok(out)
16534}
16535
16536// ── NetBIOS ──────────────────────────────────────────────────────────────────
16537
16538#[cfg(windows)]
16539fn inspect_netbios() -> Result<String, String> {
16540    let script = r#"
16541$result = [System.Text.StringBuilder]::new()
16542
16543# NetBIOS node type and WINS per adapter
16544$result.AppendLine("=== NetBIOS configuration per adapter ===") | Out-Null
16545try {
16546    $adapters = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16547        Where-Object { $_.IPEnabled -eq $true }
16548    foreach ($a in $adapters) {
16549        $nodeType = switch ($a.TcpipNetbiosOptions) {
16550            0 { "EnableNetBIOSViaDHCP" }
16551            1 { "Enabled" }
16552            2 { "Disabled" }
16553            default { "Unknown ($($a.TcpipNetbiosOptions))" }
16554        }
16555        $result.AppendLine("  [$($a.Description)]") | Out-Null
16556        $result.AppendLine("    NetBIOS over TCP/IP: $nodeType") | Out-Null
16557        if ($a.WINSPrimaryServer) {
16558            $result.AppendLine("    WINS Primary:        $($a.WINSPrimaryServer)") | Out-Null
16559        }
16560        if ($a.WINSSecondaryServer) {
16561            $result.AppendLine("    WINS Secondary:      $($a.WINSSecondaryServer)") | Out-Null
16562        }
16563    }
16564} catch {
16565    $result.AppendLine("  Could not query NetBIOS adapter config.") | Out-Null
16566}
16567
16568# nbtstat -n — registered local NetBIOS names
16569$result.AppendLine("") | Out-Null
16570$result.AppendLine("=== Registered NetBIOS names (nbtstat -n) ===") | Out-Null
16571try {
16572    $nbt = nbtstat -n 2>$null
16573    foreach ($line in $nbt) {
16574        $l = $line.Trim()
16575        if ($l -and $l -notmatch '^Node|^Host|^Registered|^-{3}') {
16576            $result.AppendLine("  $l") | Out-Null
16577        }
16578    }
16579} catch {
16580    $result.AppendLine("  nbtstat not available.") | Out-Null
16581}
16582
16583# NetBIOS session table
16584$result.AppendLine("") | Out-Null
16585$result.AppendLine("=== Active NetBIOS sessions (nbtstat -s) ===") | Out-Null
16586try {
16587    $sessions = nbtstat -s 2>$null | Where-Object { $_.Trim() -ne '' }
16588    if ($sessions) {
16589        foreach ($s in $sessions) { $result.AppendLine("  $($s.Trim())") | Out-Null }
16590    } else {
16591        $result.AppendLine("  No active NetBIOS sessions.") | Out-Null
16592    }
16593} catch {
16594    $result.AppendLine("  Could not query NetBIOS sessions.") | Out-Null
16595}
16596
16597# Findings
16598$findings = [System.Collections.Generic.List[string]]::new()
16599try {
16600    $enabled = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16601        Where-Object { $_.IPEnabled -and $_.TcpipNetbiosOptions -ne 2 }
16602    if ($enabled) {
16603        $findings.Add("NetBIOS over TCP/IP is enabled on $($enabled.Count) adapter(s) — potential attack surface if not required.")
16604    }
16605    $wins = Get-WmiObject Win32_NetworkAdapterConfiguration -ErrorAction SilentlyContinue |
16606        Where-Object { $_.WINSPrimaryServer }
16607    if ($wins) {
16608        $findings.Add("WINS server configured: $($wins[0].WINSPrimaryServer) — verify this is intentional.")
16609    }
16610} catch {}
16611
16612$result.AppendLine("") | Out-Null
16613$result.AppendLine("=== Findings ===") | Out-Null
16614if ($findings.Count -eq 0) {
16615    $result.AppendLine("- NetBIOS configuration looks standard.") | Out-Null
16616} else {
16617    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16618}
16619
16620Write-Output $result.ToString()
16621"#;
16622    let out = run_powershell(script)?;
16623    Ok(format!("Host inspection: netbios\n\n{out}"))
16624}
16625
16626#[cfg(not(windows))]
16627fn inspect_netbios() -> Result<String, String> {
16628    let mut out = String::from("Host inspection: netbios\n\n=== NetBIOS (nmblookup) ===\n");
16629    if let Ok(o) = std::process::Command::new("nmblookup")
16630        .arg("-A")
16631        .arg("localhost")
16632        .output()
16633    {
16634        out.push_str(&String::from_utf8_lossy(&o.stdout));
16635    } else {
16636        out.push_str("  nmblookup not available (Samba not installed).\n");
16637    }
16638    Ok(out)
16639}
16640
16641// ── NIC Teaming ──────────────────────────────────────────────────────────────
16642
16643#[cfg(windows)]
16644fn inspect_nic_teaming() -> Result<String, String> {
16645    let script = r#"
16646$result = [System.Text.StringBuilder]::new()
16647
16648# Team inventory
16649$result.AppendLine("=== NIC teams ===") | Out-Null
16650try {
16651    $teams = Get-NetLbfoTeam -ErrorAction SilentlyContinue
16652    if ($teams) {
16653        foreach ($t in $teams) {
16654            $result.AppendLine("  Team: $($t.Name)") | Out-Null
16655            $result.AppendLine("    Mode:            $($t.TeamingMode)") | Out-Null
16656            $result.AppendLine("    LB Algorithm:    $($t.LoadBalancingAlgorithm)") | Out-Null
16657            $result.AppendLine("    Status:          $($t.Status)") | Out-Null
16658            $result.AppendLine("    Members:         $($t.Members -join ', ')") | Out-Null
16659            $result.AppendLine("    VLANs:           $($t.TransmitLinkSpeed / 1000000) Mbps TX / $($t.ReceiveLinkSpeed / 1000000) Mbps RX") | Out-Null
16660        }
16661    } else {
16662        $result.AppendLine("  No NIC teams configured on this machine.") | Out-Null
16663    }
16664} catch {
16665    $result.AppendLine("  Get-NetLbfoTeam unavailable (feature may not be installed).") | Out-Null
16666}
16667
16668# Team members detail
16669$result.AppendLine("") | Out-Null
16670$result.AppendLine("=== Team member detail ===") | Out-Null
16671try {
16672    $members = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue
16673    if ($members) {
16674        foreach ($m in $members) {
16675            $result.AppendLine("  [$($m.Team)] $($m.Name)  Role=$($m.AdministrativeMode)  Status=$($m.OperationalStatus)") | Out-Null
16676        }
16677    } else {
16678        $result.AppendLine("  No team members found.") | Out-Null
16679    }
16680} catch {
16681    $result.AppendLine("  Could not query team members.") | Out-Null
16682}
16683
16684# Findings
16685$findings = [System.Collections.Generic.List[string]]::new()
16686try {
16687    $degraded = Get-NetLbfoTeam -ErrorAction SilentlyContinue | Where-Object { $_.Status -ne 'Up' }
16688    if ($degraded) {
16689        foreach ($d in $degraded) { $findings.Add("Team '$($d.Name)' is in degraded state: $($d.Status)") }
16690    }
16691    $downMembers = Get-NetLbfoTeamMember -ErrorAction SilentlyContinue | Where-Object { $_.OperationalStatus -ne 'Active' }
16692    if ($downMembers) {
16693        foreach ($m in $downMembers) { $findings.Add("Team member '$($m.Name)' in team '$($m.Team)' is not Active: $($m.OperationalStatus)") }
16694    }
16695} catch {}
16696
16697$result.AppendLine("") | Out-Null
16698$result.AppendLine("=== Findings ===") | Out-Null
16699if ($findings.Count -eq 0) {
16700    $result.AppendLine("- NIC teaming state looks healthy (or no teams configured).") | Out-Null
16701} else {
16702    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16703}
16704
16705Write-Output $result.ToString()
16706"#;
16707    let out = run_powershell(script)?;
16708    Ok(format!("Host inspection: nic_teaming\n\n{out}"))
16709}
16710
16711#[cfg(not(windows))]
16712fn inspect_nic_teaming() -> Result<String, String> {
16713    let mut out = String::from("Host inspection: nic_teaming\n\n=== Bond interfaces ===\n");
16714    if let Ok(o) = std::process::Command::new("cat")
16715        .arg("/proc/net/bonding/bond0")
16716        .output()
16717    {
16718        if o.status.success() {
16719            out.push_str(&String::from_utf8_lossy(&o.stdout));
16720        } else {
16721            out.push_str("  No bond0 interface found.\n");
16722        }
16723    }
16724    if let Ok(o) = std::process::Command::new("ip")
16725        .args(["link", "show", "type", "bond"])
16726        .output()
16727    {
16728        let body = String::from_utf8_lossy(&o.stdout);
16729        if !body.trim().is_empty() {
16730            out.push_str("\n=== Bond links (ip link) ===\n");
16731            out.push_str(&body);
16732        }
16733    }
16734    Ok(out)
16735}
16736
16737// ── SNMP ─────────────────────────────────────────────────────────────────────
16738
16739#[cfg(windows)]
16740fn inspect_snmp() -> Result<String, String> {
16741    let script = r#"
16742$result = [System.Text.StringBuilder]::new()
16743
16744# SNMP service state
16745$result.AppendLine("=== SNMP service state ===") | Out-Null
16746$svc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
16747if ($svc) {
16748    $result.AppendLine("  SNMP Agent service: $($svc.Status) (Startup: $($svc.StartType))") | Out-Null
16749} else {
16750    $result.AppendLine("  SNMP Agent service not installed.") | Out-Null
16751}
16752
16753$svcTrap = Get-Service -Name 'SNMPTRAP' -ErrorAction SilentlyContinue
16754if ($svcTrap) {
16755    $result.AppendLine("  SNMP Trap service:  $($svcTrap.Status) (Startup: $($svcTrap.StartType))") | Out-Null
16756}
16757
16758# Community strings (presence only — values redacted)
16759$result.AppendLine("") | Out-Null
16760$result.AppendLine("=== SNMP community strings (presence only) ===") | Out-Null
16761try {
16762    $communities = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
16763    if ($communities) {
16764        $names = $communities.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Name
16765        if ($names) {
16766            foreach ($n in $names) {
16767                $result.AppendLine("  Community: '$n'  (value redacted)") | Out-Null
16768            }
16769        } else {
16770            $result.AppendLine("  No community strings configured.") | Out-Null
16771        }
16772    } else {
16773        $result.AppendLine("  Registry key not found (SNMP may not be configured).") | Out-Null
16774    }
16775} catch {
16776    $result.AppendLine("  Could not read community strings (SNMP not configured or access denied).") | Out-Null
16777}
16778
16779# Permitted managers
16780$result.AppendLine("") | Out-Null
16781$result.AppendLine("=== Permitted SNMP managers ===") | Out-Null
16782try {
16783    $managers = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\PermittedManagers' -ErrorAction SilentlyContinue
16784    if ($managers) {
16785        $mgrs = $managers.PSObject.Properties | Where-Object { $_.Name -notmatch '^PS' } | Select-Object -ExpandProperty Value
16786        if ($mgrs) {
16787            foreach ($m in $mgrs) { $result.AppendLine("  $m") | Out-Null }
16788        } else {
16789            $result.AppendLine("  No permitted managers configured (accepts from any host).") | Out-Null
16790        }
16791    } else {
16792        $result.AppendLine("  No manager restrictions configured.") | Out-Null
16793    }
16794} catch {
16795    $result.AppendLine("  Could not read permitted managers.") | Out-Null
16796}
16797
16798# Findings
16799$findings = [System.Collections.Generic.List[string]]::new()
16800$snmpSvc = Get-Service -Name 'SNMP' -ErrorAction SilentlyContinue
16801if ($snmpSvc -and $snmpSvc.Status -eq 'Running') {
16802    $findings.Add("SNMP Agent is running — verify community strings and permitted managers are locked down.")
16803    try {
16804        $comms = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\SNMP\Parameters\ValidCommunities' -ErrorAction SilentlyContinue
16805        $publicExists = $comms.PSObject.Properties | Where-Object { $_.Name -eq 'public' }
16806        if ($publicExists) { $findings.Add("Community string 'public' is configured — this is a well-known default and a security risk.") }
16807    } catch {}
16808}
16809
16810$result.AppendLine("") | Out-Null
16811$result.AppendLine("=== Findings ===") | Out-Null
16812if ($findings.Count -eq 0) {
16813    $result.AppendLine("- SNMP agent is not running (or not installed). No exposure.") | Out-Null
16814} else {
16815    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16816}
16817
16818Write-Output $result.ToString()
16819"#;
16820    let out = run_powershell(script)?;
16821    Ok(format!("Host inspection: snmp\n\n{out}"))
16822}
16823
16824#[cfg(not(windows))]
16825fn inspect_snmp() -> Result<String, String> {
16826    let mut out = String::from("Host inspection: snmp\n\n=== SNMP daemon state ===\n");
16827    for svc in &["snmpd", "snmp"] {
16828        if let Ok(o) = std::process::Command::new("systemctl")
16829            .args(["is-active", svc])
16830            .output()
16831        {
16832            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
16833            out.push_str(&format!("  {svc}: {status}\n"));
16834        }
16835    }
16836    out.push_str("\n=== snmpd.conf community strings (presence check) ===\n");
16837    if let Ok(o) = std::process::Command::new("grep")
16838        .args(["-i", "community", "/etc/snmp/snmpd.conf"])
16839        .output()
16840    {
16841        if o.status.success() {
16842            for line in String::from_utf8_lossy(&o.stdout).lines() {
16843                out.push_str(&format!("  {line}\n"));
16844            }
16845        } else {
16846            out.push_str("  /etc/snmp/snmpd.conf not found or no community lines.\n");
16847        }
16848    }
16849    Ok(out)
16850}
16851
16852// ── Port Test ─────────────────────────────────────────────────────────────────
16853
16854#[cfg(windows)]
16855fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
16856    let target_host = host.unwrap_or("8.8.8.8");
16857    let target_port = port.unwrap_or(443);
16858
16859    let script = format!(
16860        r#"
16861$result = [System.Text.StringBuilder]::new()
16862$result.AppendLine("=== Port reachability test ===") | Out-Null
16863$result.AppendLine("  Target: {target_host}:{target_port}") | Out-Null
16864$result.AppendLine("") | Out-Null
16865
16866try {{
16867    $test = Test-NetConnection -ComputerName '{target_host}' -Port {target_port} -WarningAction SilentlyContinue -ErrorAction SilentlyContinue
16868    if ($test) {{
16869        $status = if ($test.TcpTestSucceeded) {{ "OPEN (reachable)" }} else {{ "CLOSED or FILTERED" }}
16870        $result.AppendLine("  Result:          $status") | Out-Null
16871        $result.AppendLine("  Remote address:  $($test.RemoteAddress)") | Out-Null
16872        $result.AppendLine("  Remote port:     $($test.RemotePort)") | Out-Null
16873        if ($test.PingSucceeded) {{
16874            $result.AppendLine("  ICMP ping:       Succeeded ($($test.PingReplyDetails.RoundtripTime) ms)") | Out-Null
16875        }} else {{
16876            $result.AppendLine("  ICMP ping:       Failed (host may block ICMP)") | Out-Null
16877        }}
16878        $result.AppendLine("  Interface used:  $($test.InterfaceAlias)") | Out-Null
16879        $result.AppendLine("  Source address:  $($test.SourceAddress.IPAddress)") | Out-Null
16880
16881        $result.AppendLine("") | Out-Null
16882        $result.AppendLine("=== Findings ===") | Out-Null
16883        if ($test.TcpTestSucceeded) {{
16884            $result.AppendLine("- Port {target_port} on {target_host} is OPEN — TCP handshake succeeded.") | Out-Null
16885        }} else {{
16886            $result.AppendLine("- Port {target_port} on {target_host} is CLOSED or FILTERED — TCP handshake failed.") | Out-Null
16887            $result.AppendLine("  Check: firewall rules, route to host, or service not listening on that port.") | Out-Null
16888        }}
16889    }}
16890}} catch {{
16891    $result.AppendLine("  Test-NetConnection failed: $($_.Exception.Message)") | Out-Null
16892}}
16893
16894Write-Output $result.ToString()
16895"#
16896    );
16897    let out = run_powershell(&script)?;
16898    Ok(format!("Host inspection: port_test\n\n{out}"))
16899}
16900
16901#[cfg(not(windows))]
16902fn inspect_port_test(host: Option<&str>, port: Option<u16>) -> Result<String, String> {
16903    let target_host = host.unwrap_or("8.8.8.8");
16904    let target_port = port.unwrap_or(443);
16905    let mut out = format!("Host inspection: port_test\n\n=== Port reachability test ===\n  Target: {target_host}:{target_port}\n\n");
16906    // nc -zv with timeout
16907    let nc = std::process::Command::new("nc")
16908        .args(["-zv", "-w", "3", target_host, &target_port.to_string()])
16909        .output();
16910    match nc {
16911        Ok(o) => {
16912            let stderr = String::from_utf8_lossy(&o.stderr);
16913            let stdout = String::from_utf8_lossy(&o.stdout);
16914            let body = if !stdout.trim().is_empty() {
16915                stdout.as_ref()
16916            } else {
16917                stderr.as_ref()
16918            };
16919            out.push_str(&format!("  {}\n", body.trim()));
16920            out.push_str("\n=== Findings ===\n");
16921            if o.status.success() {
16922                out.push_str(&format!("- Port {target_port} on {target_host} is OPEN.\n"));
16923            } else {
16924                out.push_str(&format!(
16925                    "- Port {target_port} on {target_host} is CLOSED or FILTERED.\n"
16926                ));
16927            }
16928        }
16929        Err(e) => out.push_str(&format!("  nc not available: {e}\n")),
16930    }
16931    Ok(out)
16932}
16933
16934// ── Network Profile ───────────────────────────────────────────────────────────
16935
16936#[cfg(windows)]
16937fn inspect_network_profile() -> Result<String, String> {
16938    let script = r#"
16939$result = [System.Text.StringBuilder]::new()
16940
16941$result.AppendLine("=== Network location profiles ===") | Out-Null
16942try {
16943    $profiles = Get-NetConnectionProfile -ErrorAction SilentlyContinue
16944    if ($profiles) {
16945        foreach ($p in $profiles) {
16946            $result.AppendLine("  Interface: $($p.InterfaceAlias)") | Out-Null
16947            $result.AppendLine("    Network name:    $($p.Name)") | Out-Null
16948            $result.AppendLine("    Category:        $($p.NetworkCategory)") | Out-Null
16949            $result.AppendLine("    IPv4 conn:       $($p.IPv4Connectivity)") | Out-Null
16950            $result.AppendLine("    IPv6 conn:       $($p.IPv6Connectivity)") | Out-Null
16951            $result.AppendLine("") | Out-Null
16952        }
16953    } else {
16954        $result.AppendLine("  No network connection profiles found.") | Out-Null
16955    }
16956} catch {
16957    $result.AppendLine("  Could not query network profiles.") | Out-Null
16958}
16959
16960# Findings
16961$findings = [System.Collections.Generic.List[string]]::new()
16962try {
16963    $pub = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'Public' }
16964    if ($pub) {
16965        foreach ($p in $pub) {
16966            $findings.Add("Interface '$($p.InterfaceAlias)' is set to Public — firewall restrictions are maximum. Change to Private if this is a trusted network.")
16967        }
16968    }
16969    $domain = Get-NetConnectionProfile -ErrorAction SilentlyContinue | Where-Object { $_.NetworkCategory -eq 'DomainAuthenticated' }
16970    if ($domain) {
16971        foreach ($d in $domain) {
16972            $findings.Add("Interface '$($d.InterfaceAlias)' is domain-authenticated — domain GPO firewall rules apply.")
16973        }
16974    }
16975} catch {}
16976
16977$result.AppendLine("=== Findings ===") | Out-Null
16978if ($findings.Count -eq 0) {
16979    $result.AppendLine("- Network profiles look normal.") | Out-Null
16980} else {
16981    foreach ($f in $findings) { $result.AppendLine("- $f") | Out-Null }
16982}
16983
16984Write-Output $result.ToString()
16985"#;
16986    let out = run_powershell(script)?;
16987    Ok(format!("Host inspection: network_profile\n\n{out}"))
16988}
16989
16990#[cfg(not(windows))]
16991fn inspect_network_profile() -> Result<String, String> {
16992    let mut out = String::from(
16993        "Host inspection: network_profile\n\n=== Network manager connection profiles ===\n",
16994    );
16995    if let Ok(o) = std::process::Command::new("nmcli")
16996        .args([
16997            "-t",
16998            "-f",
16999            "NAME,TYPE,STATE,DEVICE",
17000            "connection",
17001            "show",
17002            "--active",
17003        ])
17004        .output()
17005    {
17006        out.push_str(&String::from_utf8_lossy(&o.stdout));
17007    } else {
17008        out.push_str("  nmcli not available.\n");
17009    }
17010    Ok(out)
17011}