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    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        "search_index" | "windows_search" | "indexing" | "search" => {
51            inspect_search_index(max_entries)
52        }
53        "services" => inspect_services(parse_name_filter(args), max_entries),
54        "processes" => inspect_processes(parse_name_filter(args), max_entries),
55        "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
56        "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
57        "disk" => {
58            let path = resolve_optional_path(args)?;
59            inspect_disk(path, max_entries).await
60        }
61        "ports" => inspect_ports(parse_port_filter(args), max_entries),
62        "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
63        "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
64        "health_report" | "system_health" => inspect_health_report(),
65        "storage" => inspect_storage(max_entries),
66        "hardware" => inspect_hardware(),
67        "updates" | "windows_update" => inspect_updates(),
68        "security" | "antivirus" | "defender" => inspect_security(),
69        "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
70        "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
71        "battery" => inspect_battery(),
72        "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
73        "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
74        "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
75        "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
76        "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
77        "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
78        "vpn" => inspect_vpn(),
79        "proxy" | "proxy_settings" => inspect_proxy(),
80        "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
81        "traceroute" | "tracert" | "trace_route" | "trace" => {
82            let host = args
83                .get("host")
84                .and_then(|v| v.as_str())
85                .unwrap_or("8.8.8.8")
86                .to_string();
87            inspect_traceroute(&host, max_entries)
88        }
89        "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
90        "arp" | "arp_table" => inspect_arp(),
91        "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
92        "os_config" | "system_config" => inspect_os_config(),
93        "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
94        "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
95        "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
96        "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
97        "docker_filesystems" | "docker_mounts" | "docker_storage" | "container_mounts" => {
98            inspect_docker_filesystems(max_entries)
99        }
100        "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
101        "wsl_filesystems" | "wsl_storage" | "wsl_mounts" => inspect_wsl_filesystems(max_entries),
102        "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
103        "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
104        "git_config" | "git_global" => inspect_git_config(),
105        "databases" | "database" | "db_services" | "db" => inspect_databases(),
106        "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
107        "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
108        "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
109        "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
110        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
111        "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
112        "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
113        "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
114        "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
115        "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
116        "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
117        "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
118        "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
119        "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
120        "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
121        "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
122        "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
123        "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
124        "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
125        "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
126        "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
127        "repo_doctor" => {
128            let path = resolve_optional_path(args)?;
129            inspect_repo_doctor(path, max_entries)
130        }
131        "directory" => {
132            let raw_path = args
133                .get("path")
134                .and_then(|v| v.as_str())
135                .ok_or_else(|| {
136                    "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
137                        .to_string()
138                })?;
139            let resolved = resolve_path(raw_path)?;
140            inspect_directory("Directory", resolved, max_entries).await
141        }
142        "disk_benchmark" | "stress_test" | "io_intensity" => {
143            let path = resolve_optional_path(args)?;
144            inspect_disk_benchmark(path).await
145        }
146        "permissions" | "acl" | "access_control" => {
147            let path = resolve_optional_path(args)?;
148            inspect_permissions(path, max_entries)
149        }
150        "login_history" | "logon_history" | "user_logins" => {
151            inspect_login_history(max_entries)
152        }
153        "share_access" | "unc_access" | "remote_share" => {
154            let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
155            inspect_share_access(path)
156        }
157        "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
158        "thermal" | "throttling" | "overheating" => inspect_thermal(),
159        "activation" | "license_status" | "slmgr" => inspect_activation(),
160        "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
161        "ad_user" | "ad" | "domain_user" => {
162            let identity = parse_name_filter(args).unwrap_or_default();
163            inspect_ad_user(&identity)
164        }
165        "dns_lookup" | "dig" | "nslookup" => {
166            let name = parse_name_filter(args).unwrap_or_default();
167            let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("SRV");
168            inspect_dns_lookup(&name, record_type)
169        }
170        "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
171        "ip_config" | "ip_detail" | "dhcp" => inspect_ip_config(),
172        "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
173        other => Err(format!(
174            "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, lan_discovery, audio, bluetooth, camera, sign_in, search_index, 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, 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.",
175            other
176        )),
177
178    }
179}
180
181fn parse_max_entries(args: &Value) -> usize {
182    args.get("max_entries")
183        .and_then(|v| v.as_u64())
184        .map(|n| n as usize)
185        .unwrap_or(DEFAULT_MAX_ENTRIES)
186        .clamp(1, MAX_ENTRIES_CAP)
187}
188
189fn parse_port_filter(args: &Value) -> Option<u16> {
190    args.get("port")
191        .and_then(|v| v.as_u64())
192        .and_then(|n| u16::try_from(n).ok())
193}
194
195fn parse_name_filter(args: &Value) -> Option<String> {
196    args.get("name")
197        .and_then(|v| v.as_str())
198        .map(str::trim)
199        .filter(|value| !value.is_empty())
200        .map(|value| value.to_string())
201}
202
203fn parse_lookback_hours(args: &Value) -> Option<u32> {
204    args.get("lookback_hours")
205        .and_then(|v| v.as_u64())
206        .map(|n| n as u32)
207}
208
209fn parse_issue_text(args: &Value) -> Option<String> {
210    args.get("issue")
211        .and_then(|v| v.as_str())
212        .map(str::trim)
213        .filter(|value| !value.is_empty())
214        .map(|value| value.to_string())
215}
216
217fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
218    match args.get("path").and_then(|v| v.as_str()) {
219        Some(raw_path) => resolve_path(raw_path),
220        None => {
221            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
222        }
223    }
224}
225
226fn inspect_summary(max_entries: usize) -> Result<String, String> {
227    let current_dir =
228        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
229    let workspace_root = crate::tools::file_ops::workspace_root();
230    let workspace_mode = workspace_mode_label(&workspace_root);
231    let path_stats = analyze_path_env();
232    let toolchains = collect_toolchains();
233
234    let mut out = String::from("Host inspection: summary\n\n");
235    out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
236    out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
237    out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
238    out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
239    out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
240    out.push_str(&format!(
241        "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
242        path_stats.total_entries,
243        path_stats.unique_entries,
244        path_stats.duplicate_entries.len(),
245        path_stats.missing_entries.len()
246    ));
247
248    if toolchains.found.is_empty() {
249        out.push_str(
250            "- Toolchains found: none of the common developer tools were detected on PATH\n",
251        );
252    } else {
253        out.push_str("- Toolchains found:\n");
254        for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
255            out.push_str(&format!("  - {}: {}\n", label, version));
256        }
257        if toolchains.found.len() > max_entries.min(8) {
258            out.push_str(&format!(
259                "  - ... {} more found tools omitted\n",
260                toolchains.found.len() - max_entries.min(8)
261            ));
262        }
263    }
264
265    if !toolchains.missing.is_empty() {
266        out.push_str(&format!(
267            "- Common tools not detected on PATH: {}\n",
268            toolchains.missing.join(", ")
269        ));
270    }
271
272    for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
273        match path {
274            Some(path) if path.exists() => match count_top_level_items(&path) {
275                Ok(count) => out.push_str(&format!(
276                    "- {}: {} top-level items at {}\n",
277                    label,
278                    count,
279                    path.display()
280                )),
281                Err(e) => out.push_str(&format!(
282                    "- {}: exists at {} but could not inspect ({})\n",
283                    label,
284                    path.display(),
285                    e
286                )),
287            },
288            Some(path) => out.push_str(&format!(
289                "- {}: expected at {} but not found\n",
290                label,
291                path.display()
292            )),
293            None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
294        }
295    }
296
297    Ok(out.trim_end().to_string())
298}
299
300fn inspect_toolchains() -> Result<String, String> {
301    let report = collect_toolchains();
302    let mut out = String::from("Host inspection: toolchains\n\n");
303
304    if report.found.is_empty() {
305        out.push_str("- No common developer tools were detected on PATH.");
306    } else {
307        out.push_str("Detected developer tools:\n");
308        for (label, version) in report.found {
309            out.push_str(&format!("- {}: {}\n", label, version));
310        }
311    }
312
313    if !report.missing.is_empty() {
314        out.push_str("\nNot detected on PATH:\n");
315        for label in report.missing {
316            out.push_str(&format!("- {}\n", label));
317        }
318    }
319
320    Ok(out.trim_end().to_string())
321}
322
323fn inspect_path(max_entries: usize) -> Result<String, String> {
324    let path_stats = analyze_path_env();
325    let mut out = String::from("Host inspection: PATH\n\n");
326    out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
327    out.push_str(&format!(
328        "- Unique entries: {}\n",
329        path_stats.unique_entries
330    ));
331    out.push_str(&format!(
332        "- Duplicate entries: {}\n",
333        path_stats.duplicate_entries.len()
334    ));
335    out.push_str(&format!(
336        "- Missing paths: {}\n",
337        path_stats.missing_entries.len()
338    ));
339
340    out.push_str("\nPATH entries:\n");
341    for entry in path_stats.entries.iter().take(max_entries) {
342        out.push_str(&format!("- {}\n", entry));
343    }
344    if path_stats.entries.len() > max_entries {
345        out.push_str(&format!(
346            "- ... {} more entries omitted\n",
347            path_stats.entries.len() - max_entries
348        ));
349    }
350
351    if !path_stats.duplicate_entries.is_empty() {
352        out.push_str("\nDuplicate entries:\n");
353        for entry in path_stats.duplicate_entries.iter().take(max_entries) {
354            out.push_str(&format!("- {}\n", entry));
355        }
356        if path_stats.duplicate_entries.len() > max_entries {
357            out.push_str(&format!(
358                "- ... {} more duplicates omitted\n",
359                path_stats.duplicate_entries.len() - max_entries
360            ));
361        }
362    }
363
364    if !path_stats.missing_entries.is_empty() {
365        out.push_str("\nMissing directories:\n");
366        for entry in path_stats.missing_entries.iter().take(max_entries) {
367            out.push_str(&format!("- {}\n", entry));
368        }
369        if path_stats.missing_entries.len() > max_entries {
370            out.push_str(&format!(
371                "- ... {} more missing entries omitted\n",
372                path_stats.missing_entries.len() - max_entries
373            ));
374        }
375    }
376
377    Ok(out.trim_end().to_string())
378}
379
380fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
381    let path_stats = analyze_path_env();
382    let toolchains = collect_toolchains();
383    let package_managers = collect_package_managers();
384    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
385
386    let mut out = String::from("Host inspection: env_doctor\n\n");
387    out.push_str(&format!(
388        "- PATH health: {} duplicates, {} missing entries\n",
389        path_stats.duplicate_entries.len(),
390        path_stats.missing_entries.len()
391    ));
392    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
393    out.push_str(&format!(
394        "- Package managers found: {}\n",
395        package_managers.found.len()
396    ));
397
398    if !package_managers.found.is_empty() {
399        out.push_str("\nPackage managers:\n");
400        for (label, version) in package_managers.found.iter().take(max_entries) {
401            out.push_str(&format!("- {}: {}\n", label, version));
402        }
403        if package_managers.found.len() > max_entries {
404            out.push_str(&format!(
405                "- ... {} more package managers omitted\n",
406                package_managers.found.len() - max_entries
407            ));
408        }
409    }
410
411    if !path_stats.duplicate_entries.is_empty() {
412        out.push_str("\nDuplicate PATH entries:\n");
413        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
414            out.push_str(&format!("- {}\n", entry));
415        }
416        if path_stats.duplicate_entries.len() > max_entries.min(5) {
417            out.push_str(&format!(
418                "- ... {} more duplicate entries omitted\n",
419                path_stats.duplicate_entries.len() - max_entries.min(5)
420            ));
421        }
422    }
423
424    if !path_stats.missing_entries.is_empty() {
425        out.push_str("\nMissing PATH entries:\n");
426        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
427            out.push_str(&format!("- {}\n", entry));
428        }
429        if path_stats.missing_entries.len() > max_entries.min(5) {
430            out.push_str(&format!(
431                "- ... {} more missing entries omitted\n",
432                path_stats.missing_entries.len() - max_entries.min(5)
433            ));
434        }
435    }
436
437    if !findings.is_empty() {
438        out.push_str("\nFindings:\n");
439        for finding in findings.iter().take(max_entries.max(5)) {
440            out.push_str(&format!("- {}\n", finding));
441        }
442        if findings.len() > max_entries.max(5) {
443            out.push_str(&format!(
444                "- ... {} more findings omitted\n",
445                findings.len() - max_entries.max(5)
446            ));
447        }
448    } else {
449        out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
450    }
451
452    out.push_str(
453        "\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.",
454    );
455
456    Ok(out.trim_end().to_string())
457}
458
459#[derive(Clone, Copy, Debug, Eq, PartialEq)]
460enum FixPlanKind {
461    EnvPath,
462    PortConflict,
463    LmStudio,
464    DriverInstall,
465    GroupPolicy,
466    FirewallRule,
467    SshKey,
468    WslSetup,
469    ServiceConfig,
470    WindowsActivation,
471    RegistryEdit,
472    ScheduledTaskCreate,
473    DiskCleanup,
474    DnsResolution,
475    Generic,
476}
477
478async fn inspect_fix_plan(
479    issue: Option<String>,
480    port_filter: Option<u16>,
481    max_entries: usize,
482) -> Result<String, String> {
483    let issue = issue.unwrap_or_else(|| {
484        "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
485            .to_string()
486    });
487    let plan_kind = classify_fix_plan_kind(&issue, port_filter);
488    match plan_kind {
489        FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
490        FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
491        FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
492        FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
493        FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
494        FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
495        FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
496        FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
497        FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
498        FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
499        FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
500        FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
501        FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
502        FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
503        FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
504    }
505}
506
507fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
508    let lower = issue.to_ascii_lowercase();
509    // FirewallRule must be checked before PortConflict — "open port 80 in the firewall"
510    // is firewall rule creation, not a port ownership conflict.
511    if lower.contains("firewall rule")
512        || lower.contains("inbound rule")
513        || lower.contains("outbound rule")
514        || (lower.contains("firewall")
515            && (lower.contains("allow")
516                || lower.contains("block")
517                || lower.contains("create")
518                || lower.contains("open")))
519    {
520        FixPlanKind::FirewallRule
521    } else if port_filter.is_some()
522        || lower.contains("port ")
523        || lower.contains("address already in use")
524        || lower.contains("already in use")
525        || lower.contains("what owns port")
526        || lower.contains("listening on port")
527    {
528        FixPlanKind::PortConflict
529    } else if lower.contains("lm studio")
530        || lower.contains("localhost:1234")
531        || lower.contains("/v1/models")
532        || lower.contains("no coding model loaded")
533        || lower.contains("embedding model")
534        || lower.contains("server on port 1234")
535        || lower.contains("runtime refresh")
536    {
537        FixPlanKind::LmStudio
538    } else if lower.contains("driver")
539        || lower.contains("gpu driver")
540        || lower.contains("nvidia driver")
541        || lower.contains("amd driver")
542        || lower.contains("install driver")
543        || lower.contains("update driver")
544    {
545        FixPlanKind::DriverInstall
546    } else if lower.contains("group policy")
547        || lower.contains("gpedit")
548        || lower.contains("local policy")
549        || lower.contains("secpol")
550        || lower.contains("administrative template")
551    {
552        FixPlanKind::GroupPolicy
553    } else if lower.contains("ssh key")
554        || lower.contains("ssh-keygen")
555        || lower.contains("generate ssh")
556        || lower.contains("authorized_keys")
557        || lower.contains("id_rsa")
558        || lower.contains("id_ed25519")
559    {
560        FixPlanKind::SshKey
561    } else if lower.contains("wsl")
562        || lower.contains("windows subsystem for linux")
563        || lower.contains("install ubuntu")
564        || lower.contains("install linux on windows")
565        || lower.contains("wsl2")
566    {
567        FixPlanKind::WslSetup
568    } else if lower.contains("service")
569        && (lower.contains("start ")
570            || lower.contains("stop ")
571            || lower.contains("restart ")
572            || lower.contains("enable ")
573            || lower.contains("disable ")
574            || lower.contains("configure service"))
575    {
576        FixPlanKind::ServiceConfig
577    } else if lower.contains("activate windows")
578        || lower.contains("windows activation")
579        || lower.contains("product key")
580        || lower.contains("kms")
581        || lower.contains("not activated")
582    {
583        FixPlanKind::WindowsActivation
584    } else if lower.contains("registry")
585        || lower.contains("regedit")
586        || lower.contains("hklm")
587        || lower.contains("hkcu")
588        || lower.contains("reg add")
589        || lower.contains("reg delete")
590        || lower.contains("registry key")
591    {
592        FixPlanKind::RegistryEdit
593    } else if lower.contains("scheduled task")
594        || lower.contains("task scheduler")
595        || lower.contains("schtasks")
596        || lower.contains("create task")
597        || lower.contains("run on startup")
598        || lower.contains("run on schedule")
599        || lower.contains("cron")
600    {
601        FixPlanKind::ScheduledTaskCreate
602    } else if lower.contains("disk cleanup")
603        || lower.contains("free up disk")
604        || lower.contains("free up space")
605        || lower.contains("clear cache")
606        || lower.contains("disk full")
607        || lower.contains("low disk space")
608        || lower.contains("reclaim space")
609    {
610        FixPlanKind::DiskCleanup
611    } else if lower.contains("cargo")
612        || lower.contains("rustc")
613        || lower.contains("path")
614        || lower.contains("package manager")
615        || lower.contains("package managers")
616        || lower.contains("toolchain")
617        || lower.contains("winget")
618        || lower.contains("choco")
619        || lower.contains("scoop")
620        || lower.contains("python")
621        || lower.contains("node")
622    {
623        FixPlanKind::EnvPath
624    } else if lower.contains("dns ")
625        || lower.contains("nameserver")
626        || lower.contains("cannot resolve")
627        || lower.contains("nslookup")
628        || lower.contains("flushdns")
629    {
630        FixPlanKind::DnsResolution
631    } else {
632        FixPlanKind::Generic
633    }
634}
635
636fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
637    let path_stats = analyze_path_env();
638    let toolchains = collect_toolchains();
639    let package_managers = collect_package_managers();
640    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
641    let found_tools = toolchains
642        .found
643        .iter()
644        .map(|(label, _)| label.as_str())
645        .collect::<HashSet<_>>();
646    let found_managers = package_managers
647        .found
648        .iter()
649        .map(|(label, _)| label.as_str())
650        .collect::<HashSet<_>>();
651
652    let mut out = String::from("Host inspection: fix_plan\n\n");
653    out.push_str(&format!("- Requested issue: {}\n", issue));
654    out.push_str("- Fix-plan type: environment/path\n");
655    out.push_str(&format!(
656        "- PATH health: {} duplicates, {} missing entries\n",
657        path_stats.duplicate_entries.len(),
658        path_stats.missing_entries.len()
659    ));
660    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
661    out.push_str(&format!(
662        "- Package managers found: {}\n",
663        package_managers.found.len()
664    ));
665
666    out.push_str("\nLikely causes:\n");
667    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
668        out.push_str(
669            "- 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",
670        );
671    }
672    if path_stats.duplicate_entries.is_empty()
673        && path_stats.missing_entries.is_empty()
674        && !findings.is_empty()
675    {
676        for finding in findings.iter().take(max_entries.max(4)) {
677            out.push_str(&format!("- {}\n", finding));
678        }
679    } else {
680        if !path_stats.duplicate_entries.is_empty() {
681            out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
682        }
683        if !path_stats.missing_entries.is_empty() {
684            out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
685        }
686    }
687    if found_tools.contains("node")
688        && !found_managers.contains("npm")
689        && !found_managers.contains("pnpm")
690    {
691        out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
692    }
693    if found_tools.contains("python")
694        && !found_managers.contains("pip")
695        && !found_managers.contains("uv")
696        && !found_managers.contains("pipx")
697    {
698        out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
699    }
700
701    out.push_str("\nFix plan:\n");
702    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");
703    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
704        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");
705    } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
706        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");
707    }
708    if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
709        out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
710    }
711    if found_tools.contains("node")
712        && !found_managers.contains("npm")
713        && !found_managers.contains("pnpm")
714    {
715        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");
716    }
717    if found_tools.contains("python")
718        && !found_managers.contains("pip")
719        && !found_managers.contains("uv")
720        && !found_managers.contains("pipx")
721    {
722        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");
723    }
724
725    if !path_stats.duplicate_entries.is_empty() {
726        out.push_str("\nExample duplicate PATH rows:\n");
727        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
728            out.push_str(&format!("- {}\n", entry));
729        }
730    }
731    if !path_stats.missing_entries.is_empty() {
732        out.push_str("\nExample missing PATH rows:\n");
733        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
734            out.push_str(&format!("- {}\n", entry));
735        }
736    }
737
738    out.push_str(
739        "\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.",
740    );
741    Ok(out.trim_end().to_string())
742}
743
744fn inspect_port_fix_plan(
745    issue: &str,
746    port_filter: Option<u16>,
747    max_entries: usize,
748) -> Result<String, String> {
749    let requested_port = port_filter.or_else(|| first_port_in_text(issue));
750    let listeners = collect_listening_ports().unwrap_or_default();
751    let mut matching = listeners;
752    if let Some(port) = requested_port {
753        matching.retain(|entry| entry.port == port);
754    }
755    let processes = collect_processes().unwrap_or_default();
756
757    let mut out = String::from("Host inspection: fix_plan\n\n");
758    out.push_str(&format!("- Requested issue: {}\n", issue));
759    out.push_str("- Fix-plan type: port_conflict\n");
760    if let Some(port) = requested_port {
761        out.push_str(&format!("- Requested port: {}\n", port));
762    } else {
763        out.push_str("- Requested port: not parsed from the issue text\n");
764    }
765    out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
766
767    if !matching.is_empty() {
768        out.push_str("\nCurrent listeners:\n");
769        for entry in matching.iter().take(max_entries.min(5)) {
770            let process_name = entry
771                .pid
772                .as_deref()
773                .and_then(|pid| pid.parse::<u32>().ok())
774                .and_then(|pid| {
775                    processes
776                        .iter()
777                        .find(|process| process.pid == pid)
778                        .map(|process| process.name.as_str())
779                })
780                .unwrap_or("unknown");
781            let pid = entry.pid.as_deref().unwrap_or("unknown");
782            out.push_str(&format!(
783                "- {} {} ({}) pid {} process {}\n",
784                entry.protocol, entry.local, entry.state, pid, process_name
785            ));
786        }
787    }
788
789    out.push_str("\nFix plan:\n");
790    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");
791    if !matching.is_empty() {
792        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");
793    } else {
794        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");
795    }
796    out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
797    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");
798    out.push_str(
799        "\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.",
800    );
801    Ok(out.trim_end().to_string())
802}
803
804async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
805    let config = crate::agent::config::load_config();
806    let configured_api = config
807        .api_url
808        .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
809    let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
810    let reachability = probe_http_endpoint(&models_url).await;
811    let embed_model = detect_loaded_embed_model(&configured_api).await;
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: lm_studio\n");
816    out.push_str(&format!("- Configured API URL: {}\n", configured_api));
817    out.push_str(&format!("- Probe URL: {}\n", models_url));
818    match &reachability {
819        EndpointProbe::Reachable(status) => {
820            out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
821        }
822        EndpointProbe::Unreachable(detail) => {
823            out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
824        }
825    }
826    out.push_str(&format!(
827        "- Embedding model loaded: {}\n",
828        embed_model.as_deref().unwrap_or("none detected")
829    ));
830
831    out.push_str("\nFix plan:\n");
832    match reachability {
833        EndpointProbe::Reachable(_) => {
834            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");
835        }
836        EndpointProbe::Unreachable(_) => {
837            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");
838        }
839    }
840    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");
841    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");
842    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");
843    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");
844    if let Some(model) = embed_model {
845        out.push_str(&format!(
846            "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
847            model
848        ));
849    }
850    if max_entries > 0 {
851        out.push_str(
852            "\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.",
853        );
854    }
855    Ok(out.trim_end().to_string())
856}
857
858fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
859    // Read GPU info from the hardware topic output for grounding
860    #[cfg(target_os = "windows")]
861    let gpu_info = {
862        let out = Command::new("powershell")
863            .args([
864                "-NoProfile",
865                "-NonInteractive",
866                "-Command",
867                "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
868            ])
869            .output()
870            .ok()
871            .and_then(|o| String::from_utf8(o.stdout).ok())
872            .unwrap_or_default();
873        out.trim().to_string()
874    };
875    #[cfg(not(target_os = "windows"))]
876    let gpu_info = String::from("(GPU detection not available on this platform)");
877
878    let mut out = String::from("Host inspection: fix_plan\n\n");
879    out.push_str(&format!("- Requested issue: {}\n", issue));
880    out.push_str("- Fix-plan type: driver_install\n");
881    if !gpu_info.is_empty() {
882        out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
883    }
884    out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
885    out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
886    out.push_str(
887        "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
888    );
889    out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
890    out.push_str("4. Download the latest driver directly from the manufacturer:\n");
891    out.push_str("   - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
892    out.push_str("   - AMD: amd.com/support (use Auto-Detect tool)\n");
893    out.push_str("   - Intel: intel.com/content/www/us/en/download-center/home.html\n");
894    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");
895    out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
896    out.push_str("\nVerification:\n");
897    out.push_str("- After reboot, run in PowerShell:\n  Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
898    out.push_str("- The DriverVersion should match what you installed.\n");
899    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.");
900    Ok(out.trim_end().to_string())
901}
902
903fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
904    // Check Windows edition — Group Policy editor is not available on Home editions
905    #[cfg(target_os = "windows")]
906    let edition = {
907        Command::new("powershell")
908            .args([
909                "-NoProfile",
910                "-NonInteractive",
911                "-Command",
912                "(Get-CimInstance Win32_OperatingSystem).Caption",
913            ])
914            .output()
915            .ok()
916            .and_then(|o| String::from_utf8(o.stdout).ok())
917            .unwrap_or_default()
918            .trim()
919            .to_string()
920    };
921    #[cfg(not(target_os = "windows"))]
922    let edition = String::from("(Windows edition detection not available)");
923
924    let is_home = edition.to_lowercase().contains("home");
925
926    let mut out = String::from("Host inspection: fix_plan\n\n");
927    out.push_str(&format!("- Requested issue: {}\n", issue));
928    out.push_str("- Fix-plan type: group_policy\n");
929    out.push_str(&format!(
930        "- Windows edition detected: {}\n",
931        if edition.is_empty() {
932            "unknown".to_string()
933        } else {
934            edition.clone()
935        }
936    ));
937
938    if is_home {
939        out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
940        out.push_str("Options on Home edition:\n");
941        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");
942        out.push_str(
943            "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
944        );
945        out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
946    } else {
947        out.push_str("\nFix plan — Editing Local Group Policy:\n");
948        out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
949        out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
950        out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
951        out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
952        out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
953        out.push_str("6. To force immediate application, run in an elevated PowerShell:\n  gpupdate /force\n");
954    }
955    out.push_str("\nVerification:\n");
956    out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
957    out.push_str(
958        "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
959    );
960    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.");
961    Ok(out.trim_end().to_string())
962}
963
964fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
965    #[cfg(target_os = "windows")]
966    let profile_state = {
967        Command::new("powershell")
968            .args([
969                "-NoProfile",
970                "-NonInteractive",
971                "-Command",
972                "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
973            ])
974            .output()
975            .ok()
976            .and_then(|o| String::from_utf8(o.stdout).ok())
977            .unwrap_or_default()
978            .trim()
979            .to_string()
980    };
981    #[cfg(not(target_os = "windows"))]
982    let profile_state = String::new();
983
984    let mut out = String::from("Host inspection: fix_plan\n\n");
985    out.push_str(&format!("- Requested issue: {}\n", issue));
986    out.push_str("- Fix-plan type: firewall_rule\n");
987    if !profile_state.is_empty() {
988        out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
989    }
990    out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
991    out.push_str("\nTo ALLOW inbound traffic on a port:\n");
992    out.push_str("  New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
993    out.push_str("\nTo BLOCK outbound traffic to an address:\n");
994    out.push_str("  New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
995    out.push_str("\nTo ALLOW an application through the firewall:\n");
996    out.push_str("  New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
997    out.push_str("\nTo REMOVE a rule you created:\n");
998    out.push_str("  Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
999    out.push_str("\nTo see existing custom rules:\n");
1000    out.push_str("  Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
1001    out.push_str("\nVerification:\n");
1002    out.push_str("- After creating the rule, test reachability from another machine or use:\n  Test-NetConnection -ComputerName localhost -Port 8080\n");
1003    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.");
1004    Ok(out.trim_end().to_string())
1005}
1006
1007fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
1008    let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
1009    let ssh_dir = home.join(".ssh");
1010    let has_ssh_dir = ssh_dir.exists();
1011    let has_ed25519 = ssh_dir.join("id_ed25519").exists();
1012    let has_rsa = ssh_dir.join("id_rsa").exists();
1013    let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
1014
1015    let mut out = String::from("Host inspection: fix_plan\n\n");
1016    out.push_str(&format!("- Requested issue: {}\n", issue));
1017    out.push_str("- Fix-plan type: ssh_key\n");
1018    out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
1019    out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1020    out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1021    out.push_str(&format!(
1022        "- authorized_keys found: {}\n",
1023        has_authorized_keys
1024    ));
1025
1026    if has_ed25519 {
1027        out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1028    }
1029
1030    out.push_str("\nFix plan — Generating an SSH key pair:\n");
1031    out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1032    out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1033    out.push_str("   ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1034    out.push_str(
1035        "   - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1036    );
1037    out.push_str("   - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1038    out.push_str("3. Start the SSH agent and add your key:\n");
1039    out.push_str("   # Windows (PowerShell, run as Admin once to enable the service):\n");
1040    out.push_str("   Set-Service -Name ssh-agent -StartupType Automatic\n");
1041    out.push_str("   Start-Service ssh-agent\n");
1042    out.push_str("   # Then add the key (normal PowerShell):\n");
1043    out.push_str("   ssh-add ~/.ssh/id_ed25519\n");
1044    out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1045    out.push_str("   # Print your public key:\n");
1046    out.push_str("   cat ~/.ssh/id_ed25519.pub\n");
1047    out.push_str("   # On the target server, append it:\n");
1048    out.push_str("   echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1049    out.push_str("   chmod 600 ~/.ssh/authorized_keys\n");
1050    out.push_str("5. Test the connection:\n");
1051    out.push_str("   ssh user@server-address\n");
1052    out.push_str("\nFor GitHub/GitLab:\n");
1053    out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1054    out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1055    out.push_str("- Test: ssh -T git@github.com\n");
1056    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.");
1057    Ok(out.trim_end().to_string())
1058}
1059
1060fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1061    #[cfg(target_os = "windows")]
1062    let wsl_status = {
1063        let out = Command::new("wsl")
1064            .args(["--status"])
1065            .output()
1066            .ok()
1067            .and_then(|o| {
1068                let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1069                let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1070                Some(format!("{}{}", stdout, stderr))
1071            })
1072            .unwrap_or_default();
1073        out.trim().to_string()
1074    };
1075    #[cfg(not(target_os = "windows"))]
1076    let wsl_status = String::new();
1077
1078    let wsl_installed =
1079        !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1080
1081    let mut out = String::from("Host inspection: fix_plan\n\n");
1082    out.push_str(&format!("- Requested issue: {}\n", issue));
1083    out.push_str("- Fix-plan type: wsl_setup\n");
1084    out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1085    if !wsl_status.is_empty() {
1086        out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1087    }
1088
1089    if wsl_installed {
1090        out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1091        out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1092        out.push_str("   Available distros: wsl --list --online\n");
1093        out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1094        out.push_str("3. Create your Linux username and password when prompted.\n");
1095    } else {
1096        out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1097        out.push_str("1. Open PowerShell as Administrator.\n");
1098        out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1099        out.push_str("   wsl --install\n");
1100        out.push_str("   (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1101        out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1102        out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1103        out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1104        out.push_str("   wsl --set-default-version 2\n");
1105        out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1106        out.push_str("   wsl --install -d Debian\n");
1107        out.push_str("   wsl --list --online   # to see all available distros\n");
1108    }
1109    out.push_str("\nVerification:\n");
1110    out.push_str("- Run: wsl --list --verbose\n");
1111    out.push_str("- You should see your distro with State: Running and Version: 2\n");
1112    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.");
1113    Ok(out.trim_end().to_string())
1114}
1115
1116fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1117    let lower = issue.to_ascii_lowercase();
1118    // Extract service name hints from the issue text
1119    let service_hint = if lower.contains("ssh") {
1120        Some("sshd")
1121    } else if lower.contains("mysql") {
1122        Some("MySQL80")
1123    } else if lower.contains("postgres") || lower.contains("postgresql") {
1124        Some("postgresql")
1125    } else if lower.contains("redis") {
1126        Some("Redis")
1127    } else if lower.contains("nginx") {
1128        Some("nginx")
1129    } else if lower.contains("apache") {
1130        Some("Apache2.4")
1131    } else {
1132        None
1133    };
1134
1135    #[cfg(target_os = "windows")]
1136    let service_state = if let Some(svc) = service_hint {
1137        Command::new("powershell")
1138            .args([
1139                "-NoProfile",
1140                "-NonInteractive",
1141                "-Command",
1142                &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1143            ])
1144            .output()
1145            .ok()
1146            .and_then(|o| String::from_utf8(o.stdout).ok())
1147            .unwrap_or_default()
1148            .trim()
1149            .to_string()
1150    } else {
1151        String::new()
1152    };
1153    #[cfg(not(target_os = "windows"))]
1154    let service_state = String::new();
1155
1156    let mut out = String::from("Host inspection: fix_plan\n\n");
1157    out.push_str(&format!("- Requested issue: {}\n", issue));
1158    out.push_str("- Fix-plan type: service_config\n");
1159    if let Some(svc) = service_hint {
1160        out.push_str(&format!("- Service detected in request: {}\n", svc));
1161    }
1162    if !service_state.is_empty() {
1163        out.push_str(&format!("- Current state: {}\n", service_state));
1164    }
1165
1166    out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1167    out.push_str("\nStart a service:\n");
1168    out.push_str("  Start-Service -Name \"ServiceName\"\n");
1169    out.push_str("\nStop a service:\n");
1170    out.push_str("  Stop-Service -Name \"ServiceName\"\n");
1171    out.push_str("\nRestart a service:\n");
1172    out.push_str("  Restart-Service -Name \"ServiceName\"\n");
1173    out.push_str("\nEnable a service to start automatically:\n");
1174    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1175    out.push_str("\nDisable a service (stops it from auto-starting):\n");
1176    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1177    out.push_str("\nFind the exact service name:\n");
1178    out.push_str("  Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1179    out.push_str("\nVerification:\n");
1180    out.push_str("  Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1181    if let Some(svc) = service_hint {
1182        out.push_str(&format!(
1183            "\nFor your detected service ({}):\n  Get-Service -Name '{}'\n",
1184            svc, svc
1185        ));
1186    }
1187    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.");
1188    Ok(out.trim_end().to_string())
1189}
1190
1191fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1192    #[cfg(target_os = "windows")]
1193    let activation_status = {
1194        Command::new("powershell")
1195            .args([
1196                "-NoProfile",
1197                "-NonInteractive",
1198                "-Command",
1199                "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 + ')' })\" }",
1200            ])
1201            .output()
1202            .ok()
1203            .and_then(|o| String::from_utf8(o.stdout).ok())
1204            .unwrap_or_default()
1205            .trim()
1206            .to_string()
1207    };
1208    #[cfg(not(target_os = "windows"))]
1209    let activation_status = String::new();
1210
1211    let is_licensed = activation_status.to_lowercase().contains("licensed")
1212        && !activation_status.to_lowercase().contains("not licensed");
1213
1214    let mut out = String::from("Host inspection: fix_plan\n\n");
1215    out.push_str(&format!("- Requested issue: {}\n", issue));
1216    out.push_str("- Fix-plan type: windows_activation\n");
1217    if !activation_status.is_empty() {
1218        out.push_str(&format!(
1219            "- Current activation state:\n{}\n",
1220            activation_status
1221        ));
1222    }
1223
1224    if is_licensed {
1225        out.push_str(
1226            "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1227        );
1228        out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1229        out.push_str("   (Forces an online activation attempt)\n");
1230        out.push_str("2. Check activation details: slmgr /dli\n");
1231    } else {
1232        out.push_str("\nFix plan — Activating Windows:\n");
1233        out.push_str("1. Check your current status first:\n");
1234        out.push_str("   slmgr /dli   (basic info)\n");
1235        out.push_str("   slmgr /dlv   (detailed — shows remaining rearms, grace period)\n");
1236        out.push_str("\n2. If you have a retail product key:\n");
1237        out.push_str("   slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX   (install key)\n");
1238        out.push_str("   slmgr /ato                                   (activate online)\n");
1239        out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1240        out.push_str("   - Go to Settings → System → Activation\n");
1241        out.push_str("   - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1242        out.push_str("   - Sign in with the Microsoft account that holds the license\n");
1243        out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1244        out.push_str("   - Contact your IT department for the KMS server address\n");
1245        out.push_str("   - Set KMS host: slmgr /skms kms.yourorg.com\n");
1246        out.push_str("   - Activate:    slmgr /ato\n");
1247    }
1248    out.push_str("\nVerification:\n");
1249    out.push_str("  slmgr /dli   — should show 'License Status: Licensed'\n");
1250    out.push_str("  Or: Settings → System → Activation → 'Windows is activated'\n");
1251    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.");
1252    Ok(out.trim_end().to_string())
1253}
1254
1255fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1256    let mut out = String::from("Host inspection: fix_plan\n\n");
1257    out.push_str(&format!("- Requested issue: {}\n", issue));
1258    out.push_str("- Fix-plan type: registry_edit\n");
1259    out.push_str(
1260        "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1261    );
1262    out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1263    out.push_str("\n1. Back up before you touch anything:\n");
1264    out.push_str("   # Export the key you're about to change (PowerShell):\n");
1265    out.push_str("   reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1266    out.push_str("   # Or export the whole registry (takes a while):\n");
1267    out.push_str("   reg export HKLM C:\\backup\\HKLM_full.reg\n");
1268    out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1269    out.push_str("   Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1270    out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1271    out.push_str(
1272        "   Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1273    );
1274    out.push_str("\n4. Create a new key:\n");
1275    out.push_str("   New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1276    out.push_str("\n5. Delete a value:\n");
1277    out.push_str("   Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1278    out.push_str("\n6. Restore from backup if something breaks:\n");
1279    out.push_str("   reg import C:\\backup\\MyKey_backup.reg\n");
1280    out.push_str("\nCommon registry hives:\n");
1281    out.push_str("  HKLM = HKEY_LOCAL_MACHINE  (machine-wide, requires Admin)\n");
1282    out.push_str("  HKCU = HKEY_CURRENT_USER   (current user, no elevation needed)\n");
1283    out.push_str("  HKCR = HKEY_CLASSES_ROOT    (file associations)\n");
1284    out.push_str("\nVerification:\n");
1285    out.push_str("  Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1286    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.");
1287    Ok(out.trim_end().to_string())
1288}
1289
1290fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1291    let mut out = String::from("Host inspection: fix_plan\n\n");
1292    out.push_str(&format!("- Requested issue: {}\n", issue));
1293    out.push_str("- Fix-plan type: scheduled_task_create\n");
1294    out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1295    out.push_str("\nExample: Run a script at 9 AM every day\n");
1296    out.push_str("  $action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1297    out.push_str("  $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1298    out.push_str("  Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1299    out.push_str("\nExample: Run at Windows startup\n");
1300    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1301    out.push_str("  Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1302    out.push_str("\nExample: Run at user logon\n");
1303    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1304    out.push_str(
1305        "  Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1306    );
1307    out.push_str("\nExample: Run every 30 minutes\n");
1308    out.push_str("  $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1309    out.push_str("\nView all tasks:\n");
1310    out.push_str("  Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1311    out.push_str("\nDelete a task:\n");
1312    out.push_str("  Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1313    out.push_str("\nRun a task immediately:\n");
1314    out.push_str("  Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1315    out.push_str("\nVerification:\n");
1316    out.push_str("  Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1317    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.");
1318    Ok(out.trim_end().to_string())
1319}
1320
1321fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1322    #[cfg(target_os = "windows")]
1323    let disk_info = {
1324        Command::new("powershell")
1325            .args([
1326                "-NoProfile",
1327                "-NonInteractive",
1328                "-Command",
1329                "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\" }",
1330            ])
1331            .output()
1332            .ok()
1333            .and_then(|o| String::from_utf8(o.stdout).ok())
1334            .unwrap_or_default()
1335            .trim()
1336            .to_string()
1337    };
1338    #[cfg(not(target_os = "windows"))]
1339    let disk_info = String::new();
1340
1341    let mut out = String::from("Host inspection: fix_plan\n\n");
1342    out.push_str(&format!("- Requested issue: {}\n", issue));
1343    out.push_str("- Fix-plan type: disk_cleanup\n");
1344    if !disk_info.is_empty() {
1345        out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1346    }
1347    out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1348    out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1349    out.push_str("   cleanmgr /sageset:1    (configure what to clean)\n");
1350    out.push_str("   cleanmgr /sagerun:1    (run the cleanup)\n");
1351    out.push_str("   Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1352    out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1353    out.push_str("   Stop-Service wuauserv\n");
1354    out.push_str("   Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1355    out.push_str("   Start-Service wuauserv\n");
1356    out.push_str("\n3. Clear Windows Temp folder:\n");
1357    out.push_str("   Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1358    out.push_str(
1359        "   Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1360    );
1361    out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1362    out.push_str("   - Rust build artifacts: cargo clean  (inside each project)\n");
1363    out.push_str("   - npm cache:  npm cache clean --force\n");
1364    out.push_str("   - pip cache:  pip cache purge\n");
1365    out.push_str(
1366        "   - Docker:     docker system prune -a  (removes all unused images/containers)\n",
1367    );
1368    out.push_str("   - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force  (will redownload on next build)\n");
1369    out.push_str("\n5. Check for large files:\n");
1370    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");
1371    out.push_str("\nVerification:\n");
1372    out.push_str(
1373        "  Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1374    );
1375    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.");
1376    Ok(out.trim_end().to_string())
1377}
1378
1379fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1380    let mut out = String::from("Host inspection: fix_plan\n\n");
1381    out.push_str(&format!("- Requested issue: {}\n", issue));
1382    out.push_str("- Fix-plan type: generic\n");
1383    out.push_str(
1384        "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1385         Structured lanes available:\n\
1386         - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1387         - Port conflict (address already in use, what owns port)\n\
1388         - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1389         - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1390         - Group Policy (gpedit, local policy, administrative template)\n\
1391         - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1392         - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1393         - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1394         - Service config (start/stop/restart/enable/disable a service)\n\
1395         - Windows activation (product key, not activated, kms)\n\
1396         - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1397         - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1398         - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1399         - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1400    );
1401    Ok(out.trim_end().to_string())
1402}
1403
1404fn inspect_resource_load() -> Result<String, String> {
1405    #[cfg(target_os = "windows")]
1406    {
1407        let output = Command::new("powershell")
1408            .args([
1409                "-NoProfile",
1410                "-Command",
1411                "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1412            ])
1413            .output()
1414            .map_err(|e| format!("Failed to run powershell: {e}"))?;
1415
1416        let text = String::from_utf8_lossy(&output.stdout);
1417        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1418
1419        let cpu_load = lines
1420            .next()
1421            .and_then(|l| l.parse::<u32>().ok())
1422            .unwrap_or(0);
1423        let mem_json = lines.collect::<Vec<_>>().join("");
1424        let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1425
1426        let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1427        let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1428        let used_kb = total_kb.saturating_sub(free_kb);
1429        let mem_percent = if total_kb > 0 {
1430            (used_kb * 100) / total_kb
1431        } else {
1432            0
1433        };
1434
1435        let mut out = String::from("Host inspection: resource_load\n\n");
1436        out.push_str("**System Performance Summary:**\n");
1437        out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1438        out.push_str(&format!(
1439            "- Memory Usage: {} / {} ({}%)\n",
1440            human_bytes(used_kb * 1024),
1441            human_bytes(total_kb * 1024),
1442            mem_percent
1443        ));
1444
1445        if cpu_load > 85 {
1446            out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1447        }
1448        if mem_percent > 90 {
1449            out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1450        }
1451
1452        Ok(out)
1453    }
1454    #[cfg(not(target_os = "windows"))]
1455    {
1456        Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1457    }
1458}
1459
1460#[derive(Debug)]
1461enum EndpointProbe {
1462    Reachable(u16),
1463    Unreachable(String),
1464}
1465
1466async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1467    let client = match reqwest::Client::builder()
1468        .timeout(std::time::Duration::from_secs(3))
1469        .build()
1470    {
1471        Ok(client) => client,
1472        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1473    };
1474
1475    match client.get(url).send().await {
1476        Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1477        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1478    }
1479}
1480
1481async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1482    let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1483    let url = format!("{}/api/v0/models", base);
1484    let client = reqwest::Client::builder()
1485        .timeout(std::time::Duration::from_secs(3))
1486        .build()
1487        .ok()?;
1488
1489    #[derive(serde::Deserialize)]
1490    struct ModelList {
1491        data: Vec<ModelEntry>,
1492    }
1493    #[derive(serde::Deserialize)]
1494    struct ModelEntry {
1495        id: String,
1496        #[serde(rename = "type", default)]
1497        model_type: String,
1498        #[serde(default)]
1499        state: String,
1500    }
1501
1502    let response = client.get(url).send().await.ok()?;
1503    let models = response.json::<ModelList>().await.ok()?;
1504    models
1505        .data
1506        .into_iter()
1507        .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1508        .map(|model| model.id)
1509}
1510
1511fn first_port_in_text(text: &str) -> Option<u16> {
1512    text.split(|c: char| !c.is_ascii_digit())
1513        .find(|fragment| !fragment.is_empty())
1514        .and_then(|fragment| fragment.parse::<u16>().ok())
1515}
1516
1517fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1518    let mut processes = collect_processes()?;
1519    if let Some(filter) = name_filter.as_deref() {
1520        let lowered = filter.to_ascii_lowercase();
1521        processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1522    }
1523    processes.sort_by(|a, b| {
1524        let a_cpu = a.cpu_percent.unwrap_or(0.0);
1525        let b_cpu = b.cpu_percent.unwrap_or(0.0);
1526        b_cpu
1527            .partial_cmp(&a_cpu)
1528            .unwrap_or(std::cmp::Ordering::Equal)
1529            .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1530            .then_with(|| a.name.cmp(&b.name))
1531            .then_with(|| a.pid.cmp(&b.pid))
1532    });
1533
1534    let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1535
1536    let mut out = String::from("Host inspection: processes\n\n");
1537    if let Some(filter) = name_filter.as_deref() {
1538        out.push_str(&format!("- Filter name: {}\n", filter));
1539    }
1540    out.push_str(&format!("- Processes found: {}\n", processes.len()));
1541    out.push_str(&format!(
1542        "- Total reported working set: {}\n",
1543        human_bytes(total_memory)
1544    ));
1545
1546    if processes.is_empty() {
1547        out.push_str("\nNo running processes matched.");
1548        return Ok(out);
1549    }
1550
1551    out.push_str("\nTop processes by resource usage:\n");
1552    for entry in processes.iter().take(max_entries) {
1553        let cpu_str = entry
1554            .cpu_percent
1555            .map(|p| format!(" [CPU: {:.1}%]", p))
1556            .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1557            .unwrap_or_default();
1558        let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1559            format!(" [I/O R:{}/W:{}]", r, w)
1560        } else {
1561            " [I/O unknown]".to_string()
1562        };
1563        out.push_str(&format!(
1564            "- {} (pid {}) - {}{}{}{}\n",
1565            entry.name,
1566            entry.pid,
1567            human_bytes(entry.memory_bytes),
1568            cpu_str,
1569            io_str,
1570            entry
1571                .detail
1572                .as_deref()
1573                .map(|detail| format!(" [{}]", detail))
1574                .unwrap_or_default()
1575        ));
1576    }
1577    if processes.len() > max_entries {
1578        out.push_str(&format!(
1579            "- ... {} more processes omitted\n",
1580            processes.len() - max_entries
1581        ));
1582    }
1583
1584    Ok(out.trim_end().to_string())
1585}
1586
1587fn inspect_network(max_entries: usize) -> Result<String, String> {
1588    let adapters = collect_network_adapters()?;
1589    let active_count = adapters
1590        .iter()
1591        .filter(|adapter| adapter.is_active())
1592        .count();
1593    let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1594
1595    let mut out = String::from("Host inspection: network\n\n");
1596    out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1597    out.push_str(&format!("- Active adapters: {}\n", active_count));
1598    out.push_str(&format!(
1599        "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1600        exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1601    ));
1602
1603    if adapters.is_empty() {
1604        out.push_str("\nNo adapter details were detected.");
1605        return Ok(out);
1606    }
1607
1608    out.push_str("\nAdapter summary:\n");
1609    for adapter in adapters.iter().take(max_entries) {
1610        let status = if adapter.is_active() {
1611            "active"
1612        } else if adapter.disconnected {
1613            "disconnected"
1614        } else {
1615            "idle"
1616        };
1617        let mut details = vec![status.to_string()];
1618        if !adapter.ipv4.is_empty() {
1619            details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1620        }
1621        if !adapter.ipv6.is_empty() {
1622            details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1623        }
1624        if !adapter.gateways.is_empty() {
1625            details.push(format!("gateway {}", adapter.gateways.join(", ")));
1626        }
1627        if !adapter.dns_servers.is_empty() {
1628            details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1629        }
1630        out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1631    }
1632    if adapters.len() > max_entries {
1633        out.push_str(&format!(
1634            "- ... {} more adapters omitted\n",
1635            adapters.len() - max_entries
1636        ));
1637    }
1638
1639    Ok(out.trim_end().to_string())
1640}
1641
1642fn inspect_lan_discovery(max_entries: usize) -> Result<String, String> {
1643    let mut out = String::from("Host inspection: lan_discovery\n\n");
1644
1645    #[cfg(target_os = "windows")]
1646    {
1647        let n = max_entries.clamp(5, 20);
1648        let adapters = collect_network_adapters()?;
1649        let services = collect_services().unwrap_or_default();
1650        let active_adapters: Vec<&NetworkAdapter> = adapters
1651            .iter()
1652            .filter(|adapter| adapter.is_active())
1653            .collect();
1654        let gateways: Vec<String> = active_adapters
1655            .iter()
1656            .flat_map(|adapter| adapter.gateways.clone())
1657            .collect::<HashSet<_>>()
1658            .into_iter()
1659            .collect();
1660
1661        let neighbor_script = r#"
1662$neighbors = Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue |
1663    Where-Object {
1664        $_.IPAddress -notlike '127.*' -and
1665        $_.IPAddress -notlike '169.254*' -and
1666        $_.State -notin @('Unreachable','Invalid')
1667    } |
1668    Select-Object IPAddress, LinkLayerAddress, State, InterfaceAlias
1669$neighbors | ConvertTo-Json -Compress
1670"#;
1671        let neighbor_text = Command::new("powershell")
1672            .args(["-NoProfile", "-NonInteractive", "-Command", neighbor_script])
1673            .output()
1674            .ok()
1675            .and_then(|o| String::from_utf8(o.stdout).ok())
1676            .unwrap_or_default();
1677        let neighbors: Vec<(String, String, String, String)> = parse_lan_neighbors(&neighbor_text)
1678            .into_iter()
1679            .take(n)
1680            .collect();
1681
1682        let listener_script = r#"
1683Get-NetUDPEndpoint -ErrorAction SilentlyContinue |
1684    Where-Object { $_.LocalPort -in 137,138,1900,5353,5355 } |
1685    Select-Object LocalAddress, LocalPort, OwningProcess |
1686    ForEach-Object {
1687        $proc = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Name
1688        "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$proc"
1689    }
1690"#;
1691        let listener_text = Command::new("powershell")
1692            .args(["-NoProfile", "-NonInteractive", "-Command", listener_script])
1693            .output()
1694            .ok()
1695            .and_then(|o| String::from_utf8(o.stdout).ok())
1696            .unwrap_or_default();
1697        let listeners: Vec<(String, u16, String, String)> = listener_text
1698            .lines()
1699            .filter_map(|line| {
1700                let parts: Vec<&str> = line.trim().split('|').collect();
1701                if parts.len() < 4 {
1702                    return None;
1703                }
1704                Some((
1705                    parts[0].to_string(),
1706                    parts[1].parse::<u16>().ok()?,
1707                    parts[2].to_string(),
1708                    parts[3].to_string(),
1709                ))
1710            })
1711            .take(n)
1712            .collect();
1713
1714        let smb_mapping_script = r#"
1715Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } |
1716    ForEach-Object { "$($_.Name):|$($_.DisplayRoot)" }
1717"#;
1718        let smb_mappings: Vec<String> = Command::new("powershell")
1719            .args([
1720                "-NoProfile",
1721                "-NonInteractive",
1722                "-Command",
1723                smb_mapping_script,
1724            ])
1725            .output()
1726            .ok()
1727            .and_then(|o| String::from_utf8(o.stdout).ok())
1728            .unwrap_or_default()
1729            .lines()
1730            .take(n)
1731            .map(|line| line.trim().to_string())
1732            .filter(|line| !line.is_empty())
1733            .collect();
1734
1735        let smb_connections_script = r#"
1736Get-SmbConnection -ErrorAction SilentlyContinue |
1737    Select-Object ServerName, ShareName, NumOpens |
1738    ForEach-Object { "$($_.ServerName)|$($_.ShareName)|$($_.NumOpens)" }
1739"#;
1740        let smb_connections: Vec<String> = Command::new("powershell")
1741            .args([
1742                "-NoProfile",
1743                "-NonInteractive",
1744                "-Command",
1745                smb_connections_script,
1746            ])
1747            .output()
1748            .ok()
1749            .and_then(|o| String::from_utf8(o.stdout).ok())
1750            .unwrap_or_default()
1751            .lines()
1752            .take(n)
1753            .map(|line| line.trim().to_string())
1754            .filter(|line| !line.is_empty())
1755            .collect();
1756
1757        let discovery_service_names = [
1758            "FDResPub",
1759            "fdPHost",
1760            "SSDPSRV",
1761            "upnphost",
1762            "LanmanServer",
1763            "LanmanWorkstation",
1764            "lmhosts",
1765        ];
1766        let discovery_services: Vec<&ServiceEntry> = services
1767            .iter()
1768            .filter(|entry| {
1769                discovery_service_names
1770                    .iter()
1771                    .any(|name| entry.name.eq_ignore_ascii_case(name))
1772            })
1773            .collect();
1774
1775        let mut findings = Vec::new();
1776        if active_adapters.is_empty() {
1777            findings.push(AuditFinding {
1778                finding: "No active LAN adapters were detected.".to_string(),
1779                impact: "Neighborhood, SMB, mDNS, SSDP, and printer/NAS discovery cannot work without an active local interface.".to_string(),
1780                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(),
1781            });
1782        }
1783
1784        let stopped_discovery_services: Vec<&ServiceEntry> = discovery_services
1785            .iter()
1786            .copied()
1787            .filter(|entry| {
1788                !entry.status.eq_ignore_ascii_case("running")
1789                    && !entry.status.eq_ignore_ascii_case("active")
1790            })
1791            .collect();
1792        if !stopped_discovery_services.is_empty() {
1793            let names = stopped_discovery_services
1794                .iter()
1795                .map(|entry| entry.name.as_str())
1796                .collect::<Vec<_>>()
1797                .join(", ");
1798            findings.push(AuditFinding {
1799                finding: format!("Discovery-related services are not running: {names}"),
1800                impact: "Windows network neighborhood visibility, SSDP/UPnP discovery, or SMB browse behavior can look broken even when the network itself is fine.".to_string(),
1801                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(),
1802            });
1803        }
1804
1805        if listeners.is_empty() {
1806            findings.push(AuditFinding {
1807                finding: "No discovery-oriented UDP listeners were found on 137, 138, 1900, 5353, or 5355.".to_string(),
1808                impact: "NetBIOS, SSDP/UPnP, mDNS, and LLMNR discovery may be inactive on this host, so other devices may not see it automatically.".to_string(),
1809                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(),
1810            });
1811        }
1812
1813        if !active_adapters.is_empty() && neighbors.len() <= gateways.len() {
1814            findings.push(AuditFinding {
1815                finding: "Very little neighborhood evidence was observed beyond the default gateway.".to_string(),
1816                impact: "That often means discovery traffic is quiet, the LAN is isolated, or peer devices are not advertising themselves.".to_string(),
1817                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(),
1818            });
1819        }
1820
1821        out.push_str("=== Findings ===\n");
1822        if findings.is_empty() {
1823            out.push_str(
1824                "- Finding: LAN discovery signals look healthy from this inspection pass.\n",
1825            );
1826            out.push_str("  Impact: Neighborhood visibility, SMB browsing, and SSDP/mDNS discovery do not show an obvious host-side blocker.\n");
1827            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");
1828        } else {
1829            for finding in &findings {
1830                out.push_str(&format!("- Finding: {}\n", finding.finding));
1831                out.push_str(&format!("  Impact: {}\n", finding.impact));
1832                out.push_str(&format!("  Fix: {}\n", finding.fix));
1833            }
1834        }
1835
1836        out.push_str("\n=== Active adapter and gateway summary ===\n");
1837        if active_adapters.is_empty() {
1838            out.push_str("- No active adapters detected.\n");
1839        } else {
1840            for adapter in active_adapters.iter().take(n) {
1841                let ipv4 = if adapter.ipv4.is_empty() {
1842                    "no IPv4".to_string()
1843                } else {
1844                    adapter.ipv4.join(", ")
1845                };
1846                let gateway = if adapter.gateways.is_empty() {
1847                    "no gateway".to_string()
1848                } else {
1849                    adapter.gateways.join(", ")
1850                };
1851                out.push_str(&format!(
1852                    "- {} | IPv4: {} | Gateway: {}\n",
1853                    adapter.name, ipv4, gateway
1854                ));
1855            }
1856        }
1857
1858        out.push_str("\n=== Neighborhood evidence ===\n");
1859        out.push_str(&format!("- Gateway count: {}\n", gateways.len()));
1860        out.push_str(&format!(
1861            "- Neighbor entries observed: {}\n",
1862            neighbors.len()
1863        ));
1864        if neighbors.is_empty() {
1865            out.push_str("- No ARP/neighbor evidence retrieved.\n");
1866        } else {
1867            for (ip, mac, state, iface) in neighbors.iter().take(n) {
1868                out.push_str(&format!(
1869                    "- {} on {} | MAC: {} | State: {}\n",
1870                    ip, iface, mac, state
1871                ));
1872            }
1873        }
1874
1875        out.push_str("\n=== Discovery services ===\n");
1876        if discovery_services.is_empty() {
1877            out.push_str("- Discovery service status unavailable.\n");
1878        } else {
1879            for entry in discovery_services.iter().take(n) {
1880                let startup = entry.startup.as_deref().unwrap_or("unknown");
1881                out.push_str(&format!(
1882                    "- {} | Status: {} | Startup: {}\n",
1883                    entry.name, entry.status, startup
1884                ));
1885            }
1886        }
1887
1888        out.push_str("\n=== Discovery listener surface ===\n");
1889        if listeners.is_empty() {
1890            out.push_str("- No discovery-oriented UDP listeners detected.\n");
1891        } else {
1892            for (addr, port, pid, proc_name) in listeners.iter().take(n) {
1893                let label = match *port {
1894                    137 => "NetBIOS Name Service",
1895                    138 => "NetBIOS Datagram",
1896                    1900 => "SSDP/UPnP",
1897                    5353 => "mDNS",
1898                    5355 => "LLMNR",
1899                    _ => "Discovery",
1900                };
1901                let proc_label = if proc_name.is_empty() {
1902                    "unknown".to_string()
1903                } else {
1904                    proc_name.clone()
1905                };
1906                out.push_str(&format!(
1907                    "- {}:{} | {} | PID {} ({})\n",
1908                    addr, port, label, pid, proc_label
1909                ));
1910            }
1911        }
1912
1913        out.push_str("\n=== SMB and neighborhood visibility ===\n");
1914        if smb_mappings.is_empty() && smb_connections.is_empty() {
1915            out.push_str("- No mapped SMB drives or active SMB connections detected.\n");
1916        } else {
1917            if !smb_mappings.is_empty() {
1918                out.push_str("- Mapped drives:\n");
1919                for mapping in smb_mappings.iter().take(n) {
1920                    let parts: Vec<&str> = mapping.split('|').collect();
1921                    if parts.len() >= 2 {
1922                        out.push_str(&format!("  - {} -> {}\n", parts[0], parts[1]));
1923                    }
1924                }
1925            }
1926            if !smb_connections.is_empty() {
1927                out.push_str("- Active SMB connections:\n");
1928                for connection in smb_connections.iter().take(n) {
1929                    let parts: Vec<&str> = connection.split('|').collect();
1930                    if parts.len() >= 3 {
1931                        out.push_str(&format!(
1932                            "  - {}\\{} | Opens: {}\n",
1933                            parts[0], parts[1], parts[2]
1934                        ));
1935                    }
1936                }
1937            }
1938        }
1939    }
1940
1941    #[cfg(not(target_os = "windows"))]
1942    {
1943        let n = max_entries.clamp(5, 20);
1944        let adapters = collect_network_adapters()?;
1945        let arp_output = Command::new("ip")
1946            .args(["neigh"])
1947            .output()
1948            .ok()
1949            .and_then(|o| String::from_utf8(o.stdout).ok())
1950            .unwrap_or_default();
1951        let neighbors: Vec<&str> = arp_output
1952            .lines()
1953            .filter(|line| !line.trim().is_empty())
1954            .take(n)
1955            .collect();
1956
1957        out.push_str("=== Findings ===\n");
1958        if adapters.iter().any(|adapter| adapter.is_active()) {
1959            out.push_str(
1960                "- Finding: LAN discovery support is partially available on this platform.\n",
1961            );
1962            out.push_str("  Impact: Adapter and neighbor evidence can be inspected, but mDNS/UPnP coverage depends on local tools and services like Avahi.\n");
1963            out.push_str("  Fix: If discovery is failing, inspect Avahi/systemd-resolved, local firewall rules, and `udp_ports` next.\n");
1964        } else {
1965            out.push_str("- Finding: No active LAN adapters were detected.\n");
1966            out.push_str(
1967                "  Impact: Neighborhood discovery cannot work without an active interface.\n",
1968            );
1969            out.push_str("  Fix: Bring up Wi-Fi or Ethernet first, then rerun LAN discovery.\n");
1970        }
1971
1972        out.push_str("\n=== Active adapter and gateway summary ===\n");
1973        if adapters.is_empty() {
1974            out.push_str("- No adapters detected.\n");
1975        } else {
1976            for adapter in adapters.iter().take(n) {
1977                let ipv4 = if adapter.ipv4.is_empty() {
1978                    "no IPv4".to_string()
1979                } else {
1980                    adapter.ipv4.join(", ")
1981                };
1982                let gateway = if adapter.gateways.is_empty() {
1983                    "no gateway".to_string()
1984                } else {
1985                    adapter.gateways.join(", ")
1986                };
1987                out.push_str(&format!(
1988                    "- {} | IPv4: {} | Gateway: {}\n",
1989                    adapter.name, ipv4, gateway
1990                ));
1991            }
1992        }
1993
1994        out.push_str("\n=== Neighborhood evidence ===\n");
1995        if neighbors.is_empty() {
1996            out.push_str("- No neighbor entries detected.\n");
1997        } else {
1998            for line in neighbors {
1999                out.push_str(&format!("- {}\n", line.trim()));
2000            }
2001        }
2002    }
2003
2004    Ok(out.trim_end().to_string())
2005}
2006
2007fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
2008    let mut services = collect_services()?;
2009    if let Some(filter) = name_filter.as_deref() {
2010        let lowered = filter.to_ascii_lowercase();
2011        services.retain(|entry| {
2012            entry.name.to_ascii_lowercase().contains(&lowered)
2013                || entry
2014                    .display_name
2015                    .as_deref()
2016                    .map(|d| d.to_ascii_lowercase().contains(&lowered))
2017                    .unwrap_or(false)
2018        });
2019    }
2020
2021    services.sort_by(|a, b| {
2022        let a_running =
2023            a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
2024        let b_running =
2025            b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
2026        b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
2027    });
2028
2029    let running = services
2030        .iter()
2031        .filter(|entry| {
2032            entry.status.eq_ignore_ascii_case("running")
2033                || entry.status.eq_ignore_ascii_case("active")
2034        })
2035        .count();
2036    let failed = services
2037        .iter()
2038        .filter(|entry| {
2039            entry.status.eq_ignore_ascii_case("failed")
2040                || entry.status.eq_ignore_ascii_case("error")
2041                || entry.status.eq_ignore_ascii_case("stopped")
2042        })
2043        .count();
2044
2045    let mut out = String::from("Host inspection: services\n\n");
2046    if let Some(filter) = name_filter.as_deref() {
2047        out.push_str(&format!("- Filter name: {}\n", filter));
2048    }
2049    out.push_str(&format!("- Services found: {}\n", services.len()));
2050    out.push_str(&format!("- Running/active: {}\n", running));
2051    out.push_str(&format!("- Failed/stopped: {}\n", failed));
2052
2053    if services.is_empty() {
2054        out.push_str("\nNo services matched.");
2055        return Ok(out);
2056    }
2057
2058    // Split into running and stopped sections so both are always visible.
2059    let per_section = (max_entries / 2).max(5);
2060
2061    let running_services: Vec<_> = services
2062        .iter()
2063        .filter(|e| {
2064            e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
2065        })
2066        .collect();
2067    let stopped_services: Vec<_> = services
2068        .iter()
2069        .filter(|e| {
2070            e.status.eq_ignore_ascii_case("stopped")
2071                || e.status.eq_ignore_ascii_case("failed")
2072                || e.status.eq_ignore_ascii_case("error")
2073        })
2074        .collect();
2075
2076    let fmt_entry = |entry: &&ServiceEntry| {
2077        let startup = entry
2078            .startup
2079            .as_deref()
2080            .map(|v| format!(" | startup {}", v))
2081            .unwrap_or_default();
2082        let logon = entry
2083            .start_name
2084            .as_deref()
2085            .map(|v| format!(" | LogOn: {}", v))
2086            .unwrap_or_default();
2087        let display = entry
2088            .display_name
2089            .as_deref()
2090            .filter(|v| *v != &entry.name)
2091            .map(|v| format!(" [{}]", v))
2092            .unwrap_or_default();
2093        format!(
2094            "- {}{} - {}{}{}\n",
2095            entry.name, display, entry.status, startup, logon
2096        )
2097    };
2098
2099    out.push_str(&format!(
2100        "\nRunning services ({} total, showing up to {}):\n",
2101        running_services.len(),
2102        per_section
2103    ));
2104    for entry in running_services.iter().take(per_section) {
2105        out.push_str(&fmt_entry(entry));
2106    }
2107    if running_services.len() > per_section {
2108        out.push_str(&format!(
2109            "- ... {} more running services omitted\n",
2110            running_services.len() - per_section
2111        ));
2112    }
2113
2114    out.push_str(&format!(
2115        "\nStopped/failed services ({} total, showing up to {}):\n",
2116        stopped_services.len(),
2117        per_section
2118    ));
2119    for entry in stopped_services.iter().take(per_section) {
2120        out.push_str(&fmt_entry(entry));
2121    }
2122    if stopped_services.len() > per_section {
2123        out.push_str(&format!(
2124            "- ... {} more stopped services omitted\n",
2125            stopped_services.len() - per_section
2126        ));
2127    }
2128
2129    Ok(out.trim_end().to_string())
2130}
2131
2132async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
2133    inspect_directory("Disk", path, max_entries).await
2134}
2135
2136fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
2137    let mut listeners = collect_listening_ports()?;
2138    if let Some(port) = port_filter {
2139        listeners.retain(|entry| entry.port == port);
2140    }
2141    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
2142
2143    let mut out = String::from("Host inspection: ports\n\n");
2144    if let Some(port) = port_filter {
2145        out.push_str(&format!("- Filter port: {}\n", port));
2146    }
2147    out.push_str(&format!(
2148        "- Listening endpoints found: {}\n",
2149        listeners.len()
2150    ));
2151
2152    if listeners.is_empty() {
2153        out.push_str("\nNo listening endpoints matched.");
2154        return Ok(out);
2155    }
2156
2157    out.push_str("\nListening endpoints:\n");
2158    for entry in listeners.iter().take(max_entries) {
2159        let pid_str = entry
2160            .pid
2161            .as_deref()
2162            .map(|p| format!(" pid {}", p))
2163            .unwrap_or_default();
2164        let name_str = entry
2165            .process_name
2166            .as_deref()
2167            .map(|n| format!(" [{}]", n))
2168            .unwrap_or_default();
2169        out.push_str(&format!(
2170            "- {} {} ({}){}{}\n",
2171            entry.protocol, entry.local, entry.state, pid_str, name_str
2172        ));
2173    }
2174    if listeners.len() > max_entries {
2175        out.push_str(&format!(
2176            "- ... {} more listening endpoints omitted\n",
2177            listeners.len() - max_entries
2178        ));
2179    }
2180
2181    Ok(out.trim_end().to_string())
2182}
2183
2184fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
2185    if !path.exists() {
2186        return Err(format!("Path does not exist: {}", path.display()));
2187    }
2188    if !path.is_dir() {
2189        return Err(format!("Path is not a directory: {}", path.display()));
2190    }
2191
2192    let markers = collect_project_markers(&path);
2193    let hematite_state = collect_hematite_state(&path);
2194    let git_state = inspect_git_state(&path);
2195    let release_state = inspect_release_artifacts(&path);
2196
2197    let mut out = String::from("Host inspection: repo_doctor\n\n");
2198    out.push_str(&format!("- Path: {}\n", path.display()));
2199    out.push_str(&format!(
2200        "- Workspace mode: {}\n",
2201        workspace_mode_for_path(&path)
2202    ));
2203
2204    if markers.is_empty() {
2205        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");
2206    } else {
2207        out.push_str("- Project markers:\n");
2208        for marker in markers.iter().take(max_entries) {
2209            out.push_str(&format!("  - {}\n", marker));
2210        }
2211    }
2212
2213    match git_state {
2214        Some(git) => {
2215            out.push_str(&format!("- Git root: {}\n", git.root.display()));
2216            out.push_str(&format!("- Git branch: {}\n", git.branch));
2217            out.push_str(&format!("- Git status: {}\n", git.status_label()));
2218        }
2219        None => out.push_str("- Git: not inside a detected work tree\n"),
2220    }
2221
2222    out.push_str(&format!(
2223        "- Hematite docs/imports/reports: {}/{}/{}\n",
2224        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
2225    ));
2226    if hematite_state.workspace_profile {
2227        out.push_str("- Workspace profile: present\n");
2228    } else {
2229        out.push_str("- Workspace profile: absent\n");
2230    }
2231
2232    if let Some(release) = release_state {
2233        out.push_str(&format!("- Cargo version: {}\n", release.version));
2234        out.push_str(&format!(
2235            "- Windows artifacts for current version: {}/{}/{}\n",
2236            bool_label(release.portable_dir),
2237            bool_label(release.portable_zip),
2238            bool_label(release.setup_exe)
2239        ));
2240    }
2241
2242    Ok(out.trim_end().to_string())
2243}
2244
2245async fn inspect_known_directory(
2246    label: &str,
2247    path: Option<PathBuf>,
2248    max_entries: usize,
2249) -> Result<String, String> {
2250    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
2251    inspect_directory(label, path, max_entries).await
2252}
2253
2254async fn inspect_directory(
2255    label: &str,
2256    path: PathBuf,
2257    max_entries: usize,
2258) -> Result<String, String> {
2259    let label = label.to_string();
2260    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
2261        .await
2262        .map_err(|e| format!("inspect_host task failed: {e}"))?
2263}
2264
2265fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
2266    if !path.exists() {
2267        return Err(format!("Path does not exist: {}", path.display()));
2268    }
2269    if !path.is_dir() {
2270        return Err(format!("Path is not a directory: {}", path.display()));
2271    }
2272
2273    let mut top_level_entries = Vec::new();
2274    for entry in fs::read_dir(path)
2275        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
2276    {
2277        match entry {
2278            Ok(entry) => top_level_entries.push(entry),
2279            Err(_) => continue,
2280        }
2281    }
2282    top_level_entries.sort_by_key(|entry| entry.file_name());
2283
2284    let top_level_count = top_level_entries.len();
2285    let mut sample_names = Vec::new();
2286    let mut largest_entries = Vec::new();
2287    let mut aggregate = PathAggregate::default();
2288    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
2289
2290    for entry in top_level_entries {
2291        let name = entry.file_name().to_string_lossy().to_string();
2292        if sample_names.len() < max_entries {
2293            sample_names.push(name.clone());
2294        }
2295        let kind = match entry.file_type() {
2296            Ok(ft) if ft.is_dir() => "dir",
2297            Ok(ft) if ft.is_symlink() => "symlink",
2298            _ => "file",
2299        };
2300        let stats = measure_path(&entry.path(), &mut budget);
2301        aggregate.merge(&stats);
2302        largest_entries.push(LargestEntry {
2303            name,
2304            kind,
2305            bytes: stats.total_bytes,
2306        });
2307    }
2308
2309    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
2310
2311    let mut out = format!("Directory inspection: {}\n\n", label);
2312    out.push_str(&format!("- Path: {}\n", path.display()));
2313    out.push_str(&format!("- Top-level items: {}\n", top_level_count));
2314    out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
2315    out.push_str(&format!(
2316        "- Recursive directories: {}\n",
2317        aggregate.dir_count
2318    ));
2319    out.push_str(&format!(
2320        "- Total size: {}{}\n",
2321        human_bytes(aggregate.total_bytes),
2322        if aggregate.partial {
2323            " (partial scan)"
2324        } else {
2325            ""
2326        }
2327    ));
2328    if aggregate.skipped_entries > 0 {
2329        out.push_str(&format!(
2330            "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
2331            aggregate.skipped_entries
2332        ));
2333    }
2334
2335    if !largest_entries.is_empty() {
2336        out.push_str("\nLargest top-level entries:\n");
2337        for entry in largest_entries.iter().take(max_entries) {
2338            out.push_str(&format!(
2339                "- {} [{}] - {}\n",
2340                entry.name,
2341                entry.kind,
2342                human_bytes(entry.bytes)
2343            ));
2344        }
2345    }
2346
2347    if !sample_names.is_empty() {
2348        out.push_str("\nSample names:\n");
2349        for name in sample_names {
2350            out.push_str(&format!("- {}\n", name));
2351        }
2352    }
2353
2354    Ok(out.trim_end().to_string())
2355}
2356
2357fn resolve_path(raw: &str) -> Result<PathBuf, String> {
2358    let trimmed = raw.trim();
2359    if trimmed.is_empty() {
2360        return Err("Path must not be empty.".to_string());
2361    }
2362
2363    if let Some(rest) = trimmed
2364        .strip_prefix("~/")
2365        .or_else(|| trimmed.strip_prefix("~\\"))
2366    {
2367        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
2368        return Ok(home.join(rest));
2369    }
2370
2371    let path = PathBuf::from(trimmed);
2372    if path.is_absolute() {
2373        Ok(path)
2374    } else {
2375        let cwd =
2376            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
2377        let full_path = cwd.join(&path);
2378
2379        // Heuristic: If it's a relative path to .hematite or hematite.exe and doesn't exist here,
2380        // check the user's home directory.
2381        if !full_path.exists()
2382            && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
2383        {
2384            if let Some(home) = home::home_dir() {
2385                let home_path = home.join(trimmed);
2386                if home_path.exists() {
2387                    return Ok(home_path);
2388                }
2389            }
2390        }
2391
2392        Ok(full_path)
2393    }
2394}
2395
2396fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2397    workspace_mode_for_path(workspace_root)
2398}
2399
2400fn workspace_mode_for_path(path: &Path) -> &'static str {
2401    if is_project_marker_path(path) {
2402        "project"
2403    } else if path.join(".hematite").join("docs").exists()
2404        || path.join(".hematite").join("imports").exists()
2405        || path.join(".hematite").join("reports").exists()
2406    {
2407        "docs-only"
2408    } else {
2409        "general directory"
2410    }
2411}
2412
2413fn is_project_marker_path(path: &Path) -> bool {
2414    [
2415        "Cargo.toml",
2416        "package.json",
2417        "pyproject.toml",
2418        "go.mod",
2419        "composer.json",
2420        "requirements.txt",
2421        "Makefile",
2422        "justfile",
2423    ]
2424    .iter()
2425    .any(|name| path.join(name).exists())
2426        || path.join(".git").exists()
2427}
2428
2429fn preferred_shell_label() -> &'static str {
2430    #[cfg(target_os = "windows")]
2431    {
2432        "PowerShell"
2433    }
2434    #[cfg(not(target_os = "windows"))]
2435    {
2436        "sh"
2437    }
2438}
2439
2440fn desktop_dir() -> Option<PathBuf> {
2441    home::home_dir().map(|home| home.join("Desktop"))
2442}
2443
2444fn downloads_dir() -> Option<PathBuf> {
2445    home::home_dir().map(|home| home.join("Downloads"))
2446}
2447
2448fn count_top_level_items(path: &Path) -> Result<usize, String> {
2449    let mut count = 0usize;
2450    for entry in
2451        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2452    {
2453        if entry.is_ok() {
2454            count += 1;
2455        }
2456    }
2457    Ok(count)
2458}
2459
2460#[derive(Default)]
2461struct PathAggregate {
2462    total_bytes: u64,
2463    file_count: u64,
2464    dir_count: u64,
2465    skipped_entries: u64,
2466    partial: bool,
2467}
2468
2469impl PathAggregate {
2470    fn merge(&mut self, other: &PathAggregate) {
2471        self.total_bytes += other.total_bytes;
2472        self.file_count += other.file_count;
2473        self.dir_count += other.dir_count;
2474        self.skipped_entries += other.skipped_entries;
2475        self.partial |= other.partial;
2476    }
2477}
2478
2479struct LargestEntry {
2480    name: String,
2481    kind: &'static str,
2482    bytes: u64,
2483}
2484
2485fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2486    if *budget == 0 {
2487        return PathAggregate {
2488            partial: true,
2489            skipped_entries: 1,
2490            ..PathAggregate::default()
2491        };
2492    }
2493    *budget -= 1;
2494
2495    let metadata = match fs::symlink_metadata(path) {
2496        Ok(metadata) => metadata,
2497        Err(_) => {
2498            return PathAggregate {
2499                skipped_entries: 1,
2500                ..PathAggregate::default()
2501            }
2502        }
2503    };
2504
2505    let file_type = metadata.file_type();
2506    if file_type.is_symlink() {
2507        return PathAggregate {
2508            skipped_entries: 1,
2509            ..PathAggregate::default()
2510        };
2511    }
2512
2513    if metadata.is_file() {
2514        return PathAggregate {
2515            total_bytes: metadata.len(),
2516            file_count: 1,
2517            ..PathAggregate::default()
2518        };
2519    }
2520
2521    if !metadata.is_dir() {
2522        return PathAggregate::default();
2523    }
2524
2525    let mut aggregate = PathAggregate {
2526        dir_count: 1,
2527        ..PathAggregate::default()
2528    };
2529
2530    let read_dir = match fs::read_dir(path) {
2531        Ok(read_dir) => read_dir,
2532        Err(_) => {
2533            aggregate.skipped_entries += 1;
2534            return aggregate;
2535        }
2536    };
2537
2538    for child in read_dir {
2539        match child {
2540            Ok(child) => {
2541                let child_stats = measure_path(&child.path(), budget);
2542                aggregate.merge(&child_stats);
2543            }
2544            Err(_) => aggregate.skipped_entries += 1,
2545        }
2546    }
2547
2548    aggregate
2549}
2550
2551struct PathAnalysis {
2552    total_entries: usize,
2553    unique_entries: usize,
2554    entries: Vec<String>,
2555    duplicate_entries: Vec<String>,
2556    missing_entries: Vec<String>,
2557}
2558
2559fn analyze_path_env() -> PathAnalysis {
2560    let mut entries = Vec::new();
2561    let mut duplicate_entries = Vec::new();
2562    let mut missing_entries = Vec::new();
2563    let mut seen = HashSet::new();
2564
2565    let raw_path = std::env::var_os("PATH").unwrap_or_default();
2566    for path in std::env::split_paths(&raw_path) {
2567        let display = path.display().to_string();
2568        if display.trim().is_empty() {
2569            continue;
2570        }
2571
2572        let normalized = normalize_path_entry(&display);
2573        if !seen.insert(normalized) {
2574            duplicate_entries.push(display.clone());
2575        }
2576        if !path.exists() {
2577            missing_entries.push(display.clone());
2578        }
2579        entries.push(display);
2580    }
2581
2582    let total_entries = entries.len();
2583    let unique_entries = seen.len();
2584
2585    PathAnalysis {
2586        total_entries,
2587        unique_entries,
2588        entries,
2589        duplicate_entries,
2590        missing_entries,
2591    }
2592}
2593
2594fn normalize_path_entry(value: &str) -> String {
2595    #[cfg(target_os = "windows")]
2596    {
2597        value
2598            .replace('/', "\\")
2599            .trim_end_matches(['\\', '/'])
2600            .to_ascii_lowercase()
2601    }
2602    #[cfg(not(target_os = "windows"))]
2603    {
2604        value.trim_end_matches('/').to_string()
2605    }
2606}
2607
2608struct ToolchainReport {
2609    found: Vec<(String, String)>,
2610    missing: Vec<String>,
2611}
2612
2613struct PackageManagerReport {
2614    found: Vec<(String, String)>,
2615}
2616
2617#[derive(Debug, Clone)]
2618struct ProcessEntry {
2619    name: String,
2620    pid: u32,
2621    memory_bytes: u64,
2622    cpu_seconds: Option<f64>,
2623    cpu_percent: Option<f64>,
2624    read_ops: Option<u64>,
2625    write_ops: Option<u64>,
2626    detail: Option<String>,
2627}
2628
2629#[derive(Debug, Clone)]
2630struct ServiceEntry {
2631    name: String,
2632    status: String,
2633    startup: Option<String>,
2634    display_name: Option<String>,
2635    start_name: Option<String>,
2636}
2637
2638#[derive(Debug, Clone, Default)]
2639struct NetworkAdapter {
2640    name: String,
2641    ipv4: Vec<String>,
2642    ipv6: Vec<String>,
2643    gateways: Vec<String>,
2644    dns_servers: Vec<String>,
2645    disconnected: bool,
2646}
2647
2648impl NetworkAdapter {
2649    fn is_active(&self) -> bool {
2650        !self.disconnected
2651            && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2652    }
2653}
2654
2655#[derive(Debug, Clone, Copy, Default)]
2656struct ListenerExposureSummary {
2657    loopback_only: usize,
2658    wildcard_public: usize,
2659    specific_bind: usize,
2660}
2661
2662#[derive(Debug, Clone)]
2663struct ListeningPort {
2664    protocol: String,
2665    local: String,
2666    port: u16,
2667    state: String,
2668    pid: Option<String>,
2669    process_name: Option<String>,
2670}
2671
2672fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2673    #[cfg(target_os = "windows")]
2674    {
2675        collect_windows_listening_ports()
2676    }
2677    #[cfg(not(target_os = "windows"))]
2678    {
2679        collect_unix_listening_ports()
2680    }
2681}
2682
2683fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2684    #[cfg(target_os = "windows")]
2685    {
2686        collect_windows_network_adapters()
2687    }
2688    #[cfg(not(target_os = "windows"))]
2689    {
2690        collect_unix_network_adapters()
2691    }
2692}
2693
2694fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2695    #[cfg(target_os = "windows")]
2696    {
2697        collect_windows_services()
2698    }
2699    #[cfg(not(target_os = "windows"))]
2700    {
2701        collect_unix_services()
2702    }
2703}
2704
2705#[cfg(target_os = "windows")]
2706fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2707    let output = Command::new("netstat")
2708        .args(["-ano", "-p", "tcp"])
2709        .output()
2710        .map_err(|e| format!("Failed to run netstat: {e}"))?;
2711    if !output.status.success() {
2712        return Err("netstat returned a non-success status.".to_string());
2713    }
2714
2715    let text = String::from_utf8_lossy(&output.stdout);
2716    let mut listeners = Vec::new();
2717    for line in text.lines() {
2718        let trimmed = line.trim();
2719        if !trimmed.starts_with("TCP") {
2720            continue;
2721        }
2722        let cols: Vec<&str> = trimmed.split_whitespace().collect();
2723        if cols.len() < 5 || cols[3] != "LISTENING" {
2724            continue;
2725        }
2726        let Some(port) = extract_port_from_socket(cols[1]) else {
2727            continue;
2728        };
2729        listeners.push(ListeningPort {
2730            protocol: cols[0].to_string(),
2731            local: cols[1].to_string(),
2732            port,
2733            state: cols[3].to_string(),
2734            pid: Some(cols[4].to_string()),
2735            process_name: None,
2736        });
2737    }
2738
2739    // Enrich with process names via PowerShell — works without elevation for
2740    // most user-space processes. System processes (PID 4, etc.) stay unnamed.
2741    let unique_pids: Vec<String> = listeners
2742        .iter()
2743        .filter_map(|l| l.pid.clone())
2744        .collect::<HashSet<_>>()
2745        .into_iter()
2746        .collect();
2747
2748    if !unique_pids.is_empty() {
2749        let pid_list = unique_pids.join(",");
2750        let ps_cmd = format!(
2751            "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2752            pid_list
2753        );
2754        if let Ok(ps_out) = Command::new("powershell")
2755            .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2756            .output()
2757        {
2758            let mut pid_map = std::collections::HashMap::<String, String>::new();
2759            let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2760            for line in ps_text.lines() {
2761                let parts: Vec<&str> = line.split_whitespace().collect();
2762                if parts.len() >= 2 {
2763                    pid_map.insert(parts[0].to_string(), parts[1].to_string());
2764                }
2765            }
2766            for listener in &mut listeners {
2767                if let Some(pid) = &listener.pid {
2768                    listener.process_name = pid_map.get(pid).cloned();
2769                }
2770            }
2771        }
2772    }
2773
2774    Ok(listeners)
2775}
2776
2777#[cfg(not(target_os = "windows"))]
2778fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2779    let output = Command::new("ss")
2780        .args(["-ltn"])
2781        .output()
2782        .map_err(|e| format!("Failed to run ss: {e}"))?;
2783    if !output.status.success() {
2784        return Err("ss returned a non-success status.".to_string());
2785    }
2786
2787    let text = String::from_utf8_lossy(&output.stdout);
2788    let mut listeners = Vec::new();
2789    for line in text.lines().skip(1) {
2790        let cols: Vec<&str> = line.split_whitespace().collect();
2791        if cols.len() < 4 {
2792            continue;
2793        }
2794        let Some(port) = extract_port_from_socket(cols[3]) else {
2795            continue;
2796        };
2797        listeners.push(ListeningPort {
2798            protocol: "tcp".to_string(),
2799            local: cols[3].to_string(),
2800            port,
2801            state: cols[0].to_string(),
2802            pid: None,
2803            process_name: None,
2804        });
2805    }
2806
2807    Ok(listeners)
2808}
2809
2810fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
2811    #[cfg(target_os = "windows")]
2812    {
2813        collect_windows_processes()
2814    }
2815    #[cfg(not(target_os = "windows"))]
2816    {
2817        collect_unix_processes()
2818    }
2819}
2820
2821#[cfg(target_os = "windows")]
2822fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
2823    let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
2824    let output = Command::new("powershell")
2825        .args(["-NoProfile", "-Command", command])
2826        .output()
2827        .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
2828    if !output.status.success() {
2829        return Err("PowerShell service inspection returned a non-success status.".to_string());
2830    }
2831
2832    parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
2833}
2834
2835#[cfg(not(target_os = "windows"))]
2836fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
2837    let status_output = Command::new("systemctl")
2838        .args([
2839            "list-units",
2840            "--type=service",
2841            "--all",
2842            "--no-pager",
2843            "--no-legend",
2844            "--plain",
2845        ])
2846        .output()
2847        .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
2848    if !status_output.status.success() {
2849        return Err("systemctl list-units returned a non-success status.".to_string());
2850    }
2851
2852    let startup_output = Command::new("systemctl")
2853        .args([
2854            "list-unit-files",
2855            "--type=service",
2856            "--no-legend",
2857            "--no-pager",
2858            "--plain",
2859        ])
2860        .output()
2861        .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
2862    if !startup_output.status.success() {
2863        return Err("systemctl list-unit-files returned a non-success status.".to_string());
2864    }
2865
2866    Ok(parse_unix_services(
2867        &String::from_utf8_lossy(&status_output.stdout),
2868        &String::from_utf8_lossy(&startup_output.stdout),
2869    ))
2870}
2871
2872#[cfg(target_os = "windows")]
2873fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2874    let output = Command::new("ipconfig")
2875        .args(["/all"])
2876        .output()
2877        .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
2878    if !output.status.success() {
2879        return Err("ipconfig returned a non-success status.".to_string());
2880    }
2881
2882    Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
2883        &output.stdout,
2884    )))
2885}
2886
2887#[cfg(not(target_os = "windows"))]
2888fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2889    let addr_output = Command::new("ip")
2890        .args(["-o", "addr", "show", "up"])
2891        .output()
2892        .map_err(|e| format!("Failed to run ip addr: {e}"))?;
2893    if !addr_output.status.success() {
2894        return Err("ip addr returned a non-success status.".to_string());
2895    }
2896
2897    let route_output = Command::new("ip")
2898        .args(["route", "show", "default"])
2899        .output()
2900        .map_err(|e| format!("Failed to run ip route: {e}"))?;
2901    if !route_output.status.success() {
2902        return Err("ip route returned a non-success status.".to_string());
2903    }
2904
2905    let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
2906    apply_unix_default_routes(
2907        &mut adapters,
2908        &String::from_utf8_lossy(&route_output.stdout),
2909    );
2910    apply_unix_dns_servers(&mut adapters);
2911    Ok(adapters)
2912}
2913
2914#[cfg(target_os = "windows")]
2915fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
2916    // We take two samples of CPU time separated by a short interval to calculate recent CPU %
2917    let script = r#"
2918        $s1 = Get-Process | Select-Object Id, CPU
2919        Start-Sleep -Milliseconds 250
2920        $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
2921        $s2 | ForEach-Object {
2922            $p2 = $_
2923            $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
2924            $pct = 0.0
2925            if ($p1 -and $p2.CPU -gt $p1.CPU) {
2926                # (Delta CPU seconds / interval) * 100 / LogicalProcessors
2927                # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
2928                # Standard Task Manager style is (delta / interval) * 100.
2929                $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
2930            }
2931            "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
2932        }
2933    "#;
2934
2935    let output = Command::new("powershell")
2936        .args(["-NoProfile", "-Command", script])
2937        .output()
2938        .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
2939
2940    let text = String::from_utf8_lossy(&output.stdout);
2941    let mut out = Vec::new();
2942    for line in text.lines() {
2943        let parts: Vec<&str> = line.trim().split('|').collect();
2944        if parts.len() < 5 {
2945            continue;
2946        }
2947        let mut entry = ProcessEntry {
2948            name: "unknown".to_string(),
2949            pid: 0,
2950            memory_bytes: 0,
2951            cpu_seconds: None,
2952            cpu_percent: None,
2953            read_ops: None,
2954            write_ops: None,
2955            detail: None,
2956        };
2957        for p in parts {
2958            if let Some((k, v)) = p.split_once(':') {
2959                match k {
2960                    "PID" => entry.pid = v.parse().unwrap_or(0),
2961                    "NAME" => entry.name = v.to_string(),
2962                    "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
2963                    "CPU_S" => entry.cpu_seconds = v.parse().ok(),
2964                    "CPU_P" => entry.cpu_percent = v.parse().ok(),
2965                    "READ" => entry.read_ops = v.parse().ok(),
2966                    "WRITE" => entry.write_ops = v.parse().ok(),
2967                    _ => {}
2968                }
2969            }
2970        }
2971        out.push(entry);
2972    }
2973    Ok(out)
2974}
2975
2976#[cfg(not(target_os = "windows"))]
2977fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
2978    let output = Command::new("ps")
2979        .args(["-eo", "pid=,rss=,comm="])
2980        .output()
2981        .map_err(|e| format!("Failed to run ps: {e}"))?;
2982    if !output.status.success() {
2983        return Err("ps returned a non-success status.".to_string());
2984    }
2985
2986    let text = String::from_utf8_lossy(&output.stdout);
2987    let mut processes = Vec::new();
2988    for line in text.lines() {
2989        let cols: Vec<&str> = line.split_whitespace().collect();
2990        if cols.len() < 3 {
2991            continue;
2992        }
2993        let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
2994        else {
2995            continue;
2996        };
2997        processes.push(ProcessEntry {
2998            name: cols[2..].join(" "),
2999            pid,
3000            memory_bytes: rss_kib * 1024,
3001            cpu_seconds: None,
3002            cpu_percent: None,
3003            read_ops: None,
3004            write_ops: None,
3005            detail: None,
3006        });
3007    }
3008
3009    Ok(processes)
3010}
3011
3012fn extract_port_from_socket(value: &str) -> Option<u16> {
3013    let cleaned = value.trim().trim_matches(['[', ']']);
3014    let port_str = cleaned.rsplit(':').next()?;
3015    port_str.parse::<u16>().ok()
3016}
3017
3018fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
3019    let mut summary = ListenerExposureSummary::default();
3020    for entry in listeners {
3021        let local = entry.local.to_ascii_lowercase();
3022        if is_loopback_listener(&local) {
3023            summary.loopback_only += 1;
3024        } else if is_wildcard_listener(&local) {
3025            summary.wildcard_public += 1;
3026        } else {
3027            summary.specific_bind += 1;
3028        }
3029    }
3030    summary
3031}
3032
3033fn is_loopback_listener(local: &str) -> bool {
3034    local.starts_with("127.")
3035        || local.starts_with("[::1]")
3036        || local.starts_with("::1")
3037        || local.starts_with("localhost:")
3038}
3039
3040fn is_wildcard_listener(local: &str) -> bool {
3041    local.starts_with("0.0.0.0:")
3042        || local.starts_with("[::]:")
3043        || local.starts_with(":::")
3044        || local == "*:*"
3045}
3046
3047struct GitState {
3048    root: PathBuf,
3049    branch: String,
3050    dirty_entries: usize,
3051}
3052
3053impl GitState {
3054    fn status_label(&self) -> String {
3055        if self.dirty_entries == 0 {
3056            "clean".to_string()
3057        } else {
3058            format!("dirty ({} changed path(s))", self.dirty_entries)
3059        }
3060    }
3061}
3062
3063fn inspect_git_state(path: &Path) -> Option<GitState> {
3064    let root = capture_first_line(
3065        "git",
3066        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
3067    )?;
3068    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
3069        .unwrap_or_else(|| "detached".to_string());
3070    let output = Command::new("git")
3071        .args(["-C", path.to_str()?, "status", "--short"])
3072        .output()
3073        .ok()?;
3074    if !output.status.success() {
3075        return None;
3076    }
3077    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
3078    Some(GitState {
3079        root: PathBuf::from(root),
3080        branch,
3081        dirty_entries,
3082    })
3083}
3084
3085struct HematiteState {
3086    docs_count: usize,
3087    import_count: usize,
3088    report_count: usize,
3089    workspace_profile: bool,
3090}
3091
3092fn collect_hematite_state(path: &Path) -> HematiteState {
3093    let root = path.join(".hematite");
3094    HematiteState {
3095        docs_count: count_entries_if_exists(&root.join("docs")),
3096        import_count: count_entries_if_exists(&root.join("imports")),
3097        report_count: count_entries_if_exists(&root.join("reports")),
3098        workspace_profile: root.join("workspace_profile.json").exists(),
3099    }
3100}
3101
3102fn count_entries_if_exists(path: &Path) -> usize {
3103    if !path.exists() || !path.is_dir() {
3104        return 0;
3105    }
3106    fs::read_dir(path)
3107        .ok()
3108        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
3109        .unwrap_or(0)
3110}
3111
3112fn collect_project_markers(path: &Path) -> Vec<String> {
3113    [
3114        "Cargo.toml",
3115        "package.json",
3116        "pyproject.toml",
3117        "go.mod",
3118        "justfile",
3119        "Makefile",
3120        ".git",
3121    ]
3122    .iter()
3123    .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
3124    .collect()
3125}
3126
3127struct ReleaseArtifactState {
3128    version: String,
3129    portable_dir: bool,
3130    portable_zip: bool,
3131    setup_exe: bool,
3132}
3133
3134fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
3135    let cargo_toml = path.join("Cargo.toml");
3136    if !cargo_toml.exists() {
3137        return None;
3138    }
3139    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
3140    let version = [regex_line_capture(
3141        &cargo_text,
3142        r#"(?m)^version\s*=\s*"([^"]+)""#,
3143    )?]
3144    .concat();
3145    let dist_windows = path.join("dist").join("windows");
3146    let prefix = format!("Hematite-{}", version);
3147    Some(ReleaseArtifactState {
3148        version,
3149        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
3150        portable_zip: dist_windows
3151            .join(format!("{}-portable.zip", prefix))
3152            .exists(),
3153        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
3154    })
3155}
3156
3157fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
3158    let regex = regex::Regex::new(pattern).ok()?;
3159    let captures = regex.captures(text)?;
3160    captures.get(1).map(|m| m.as_str().to_string())
3161}
3162
3163fn bool_label(value: bool) -> &'static str {
3164    if value {
3165        "yes"
3166    } else {
3167        "no"
3168    }
3169}
3170
3171fn collect_toolchains() -> ToolchainReport {
3172    let checks = [
3173        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
3174        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
3175        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3176        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
3177        ToolCheck::new(
3178            "npm",
3179            &[
3180                CommandProbe::new("npm", &["--version"]),
3181                CommandProbe::new("npm.cmd", &["--version"]),
3182            ],
3183        ),
3184        ToolCheck::new(
3185            "pnpm",
3186            &[
3187                CommandProbe::new("pnpm", &["--version"]),
3188                CommandProbe::new("pnpm.cmd", &["--version"]),
3189            ],
3190        ),
3191        ToolCheck::new(
3192            "python",
3193            &[
3194                CommandProbe::new("python", &["--version"]),
3195                CommandProbe::new("python3", &["--version"]),
3196                CommandProbe::new("py", &["-3", "--version"]),
3197                CommandProbe::new("py", &["--version"]),
3198            ],
3199        ),
3200        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
3201        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
3202        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
3203        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3204    ];
3205
3206    let mut found = Vec::new();
3207    let mut missing = Vec::new();
3208
3209    for check in checks {
3210        match check.detect() {
3211            Some(version) => found.push((check.label.to_string(), version)),
3212            None => missing.push(check.label.to_string()),
3213        }
3214    }
3215
3216    ToolchainReport { found, missing }
3217}
3218
3219fn collect_package_managers() -> PackageManagerReport {
3220    let checks = [
3221        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
3222        ToolCheck::new(
3223            "npm",
3224            &[
3225                CommandProbe::new("npm", &["--version"]),
3226                CommandProbe::new("npm.cmd", &["--version"]),
3227            ],
3228        ),
3229        ToolCheck::new(
3230            "pnpm",
3231            &[
3232                CommandProbe::new("pnpm", &["--version"]),
3233                CommandProbe::new("pnpm.cmd", &["--version"]),
3234            ],
3235        ),
3236        ToolCheck::new(
3237            "pip",
3238            &[
3239                CommandProbe::new("python", &["-m", "pip", "--version"]),
3240                CommandProbe::new("python3", &["-m", "pip", "--version"]),
3241                CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
3242                CommandProbe::new("py", &["-m", "pip", "--version"]),
3243                CommandProbe::new("pip", &["--version"]),
3244            ],
3245        ),
3246        ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
3247        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
3248        ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
3249        ToolCheck::new(
3250            "choco",
3251            &[
3252                CommandProbe::new("choco", &["--version"]),
3253                CommandProbe::new("choco.exe", &["--version"]),
3254            ],
3255        ),
3256        ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
3257    ];
3258
3259    let mut found = Vec::new();
3260    for check in checks {
3261        match check.detect() {
3262            Some(version) => found.push((check.label.to_string(), version)),
3263            None => {}
3264        }
3265    }
3266
3267    PackageManagerReport { found }
3268}
3269
3270#[derive(Clone)]
3271struct ToolCheck {
3272    label: &'static str,
3273    probes: Vec<CommandProbe>,
3274}
3275
3276impl ToolCheck {
3277    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
3278        Self {
3279            label,
3280            probes: probes.to_vec(),
3281        }
3282    }
3283
3284    fn detect(&self) -> Option<String> {
3285        for probe in &self.probes {
3286            if let Some(output) = capture_first_line(probe.program, probe.args) {
3287                return Some(output);
3288            }
3289        }
3290        None
3291    }
3292}
3293
3294#[derive(Clone, Copy)]
3295struct CommandProbe {
3296    program: &'static str,
3297    args: &'static [&'static str],
3298}
3299
3300impl CommandProbe {
3301    const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
3302        Self { program, args }
3303    }
3304}
3305
3306fn build_env_doctor_findings(
3307    toolchains: &ToolchainReport,
3308    package_managers: &PackageManagerReport,
3309    path_stats: &PathAnalysis,
3310) -> Vec<String> {
3311    let found_tools = toolchains
3312        .found
3313        .iter()
3314        .map(|(label, _)| label.as_str())
3315        .collect::<HashSet<_>>();
3316    let found_managers = package_managers
3317        .found
3318        .iter()
3319        .map(|(label, _)| label.as_str())
3320        .collect::<HashSet<_>>();
3321
3322    let mut findings = Vec::new();
3323
3324    if path_stats.duplicate_entries.len() > 0 {
3325        findings.push(format!(
3326            "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
3327            path_stats.duplicate_entries.len()
3328        ));
3329    }
3330    if path_stats.missing_entries.len() > 0 {
3331        findings.push(format!(
3332            "PATH contains {} entries that do not exist on disk.",
3333            path_stats.missing_entries.len()
3334        ));
3335    }
3336    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
3337        findings.push(
3338            "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
3339                .to_string(),
3340        );
3341    }
3342    if found_tools.contains("node")
3343        && !found_managers.contains("npm")
3344        && !found_managers.contains("pnpm")
3345    {
3346        findings.push(
3347            "Node is present but no JavaScript package manager was detected (npm or pnpm)."
3348                .to_string(),
3349        );
3350    }
3351    if found_tools.contains("python")
3352        && !found_managers.contains("pip")
3353        && !found_managers.contains("uv")
3354        && !found_managers.contains("pipx")
3355    {
3356        findings.push(
3357            "Python is present but no Python package manager was detected (pip, uv, or pipx)."
3358                .to_string(),
3359        );
3360    }
3361    let windows_manager_count = ["winget", "choco", "scoop"]
3362        .iter()
3363        .filter(|label| found_managers.contains(**label))
3364        .count();
3365    if windows_manager_count > 1 {
3366        findings.push(
3367            "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
3368                .to_string(),
3369        );
3370    }
3371    if findings.is_empty() && !found_managers.is_empty() {
3372        findings.push(
3373            "Core package-manager coverage looks healthy for a normal developer workstation."
3374                .to_string(),
3375        );
3376    }
3377
3378    findings
3379}
3380
3381fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
3382    let output = std::process::Command::new(program)
3383        .args(args)
3384        .output()
3385        .ok()?;
3386    if !output.status.success() {
3387        return None;
3388    }
3389
3390    let stdout = if output.stdout.is_empty() {
3391        String::from_utf8_lossy(&output.stderr).into_owned()
3392    } else {
3393        String::from_utf8_lossy(&output.stdout).into_owned()
3394    };
3395
3396    stdout
3397        .lines()
3398        .map(str::trim)
3399        .find(|line| !line.is_empty())
3400        .map(|line| line.to_string())
3401}
3402
3403fn human_bytes(bytes: u64) -> String {
3404    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3405    let mut value = bytes as f64;
3406    let mut unit_index = 0usize;
3407
3408    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3409        value /= 1024.0;
3410        unit_index += 1;
3411    }
3412
3413    if unit_index == 0 {
3414        format!("{} {}", bytes, UNITS[unit_index])
3415    } else {
3416        format!("{value:.1} {}", UNITS[unit_index])
3417    }
3418}
3419
3420#[cfg(target_os = "windows")]
3421fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3422    let mut adapters = Vec::new();
3423    let mut current: Option<NetworkAdapter> = None;
3424    let mut pending_dns = false;
3425
3426    for raw_line in text.lines() {
3427        let line = raw_line.trim_end();
3428        let trimmed = line.trim();
3429        if trimmed.is_empty() {
3430            pending_dns = false;
3431            continue;
3432        }
3433
3434        if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3435            if let Some(adapter) = current.take() {
3436                adapters.push(adapter);
3437            }
3438            current = Some(NetworkAdapter {
3439                name: trimmed.trim_end_matches(':').to_string(),
3440                ..NetworkAdapter::default()
3441            });
3442            pending_dns = false;
3443            continue;
3444        }
3445
3446        let Some(adapter) = current.as_mut() else {
3447            continue;
3448        };
3449
3450        if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3451            adapter.disconnected = true;
3452        }
3453
3454        if let Some(value) = value_after_colon(trimmed) {
3455            let normalized = normalize_ipconfig_value(value);
3456            if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3457                adapter.ipv4.push(normalized);
3458                pending_dns = false;
3459            } else if trimmed.starts_with("IPv6 Address")
3460                || trimmed.starts_with("Temporary IPv6 Address")
3461                || trimmed.starts_with("Link-local IPv6 Address")
3462            {
3463                if !normalized.is_empty() {
3464                    adapter.ipv6.push(normalized);
3465                }
3466                pending_dns = false;
3467            } else if trimmed.starts_with("Default Gateway") {
3468                if !normalized.is_empty() {
3469                    adapter.gateways.push(normalized);
3470                }
3471                pending_dns = false;
3472            } else if trimmed.starts_with("DNS Servers") {
3473                if !normalized.is_empty() {
3474                    adapter.dns_servers.push(normalized);
3475                }
3476                pending_dns = true;
3477            } else {
3478                pending_dns = false;
3479            }
3480        } else if pending_dns {
3481            let normalized = normalize_ipconfig_value(trimmed);
3482            if !normalized.is_empty() {
3483                adapter.dns_servers.push(normalized);
3484            }
3485        }
3486    }
3487
3488    if let Some(adapter) = current.take() {
3489        adapters.push(adapter);
3490    }
3491
3492    for adapter in &mut adapters {
3493        dedup_vec(&mut adapter.ipv4);
3494        dedup_vec(&mut adapter.ipv6);
3495        dedup_vec(&mut adapter.gateways);
3496        dedup_vec(&mut adapter.dns_servers);
3497    }
3498
3499    adapters
3500}
3501
3502#[cfg(not(target_os = "windows"))]
3503fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3504    let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3505
3506    for line in text.lines() {
3507        let cols: Vec<&str> = line.split_whitespace().collect();
3508        if cols.len() < 4 {
3509            continue;
3510        }
3511        let name = cols[1].trim_end_matches(':').to_string();
3512        let family = cols[2];
3513        let addr = cols[3].split('/').next().unwrap_or("").to_string();
3514        let entry = adapters
3515            .entry(name.clone())
3516            .or_insert_with(|| NetworkAdapter {
3517                name,
3518                ..NetworkAdapter::default()
3519            });
3520        match family {
3521            "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3522            "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3523            _ => {}
3524        }
3525    }
3526
3527    adapters.into_values().collect()
3528}
3529
3530#[cfg(not(target_os = "windows"))]
3531fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3532    for line in text.lines() {
3533        let cols: Vec<&str> = line.split_whitespace().collect();
3534        if cols.len() < 5 {
3535            continue;
3536        }
3537        let gateway = cols
3538            .windows(2)
3539            .find(|pair| pair[0] == "via")
3540            .map(|pair| pair[1].to_string());
3541        let dev = cols
3542            .windows(2)
3543            .find(|pair| pair[0] == "dev")
3544            .map(|pair| pair[1]);
3545        if let (Some(gateway), Some(dev)) = (gateway, dev) {
3546            if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3547                adapter.gateways.push(gateway);
3548            }
3549        }
3550    }
3551
3552    for adapter in adapters {
3553        dedup_vec(&mut adapter.gateways);
3554    }
3555}
3556
3557#[cfg(not(target_os = "windows"))]
3558fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3559    let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3560        return;
3561    };
3562    let mut dns_servers = text
3563        .lines()
3564        .filter_map(|line| line.strip_prefix("nameserver "))
3565        .map(str::trim)
3566        .filter(|value| !value.is_empty())
3567        .map(|value| value.to_string())
3568        .collect::<Vec<_>>();
3569    dedup_vec(&mut dns_servers);
3570    if dns_servers.is_empty() {
3571        return;
3572    }
3573    for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3574        adapter.dns_servers = dns_servers.clone();
3575    }
3576}
3577
3578#[cfg(target_os = "windows")]
3579fn value_after_colon(line: &str) -> Option<&str> {
3580    line.split_once(':').map(|(_, value)| value.trim())
3581}
3582
3583#[cfg(target_os = "windows")]
3584fn normalize_ipconfig_value(value: &str) -> String {
3585    value
3586        .trim()
3587        .trim_end_matches("(Preferred)")
3588        .trim_end_matches("(Deprecated)")
3589        .trim()
3590        .trim_matches(['(', ')'])
3591        .trim()
3592        .to_string()
3593}
3594
3595#[cfg(target_os = "windows")]
3596fn is_noise_lan_neighbor(ip: &str, mac: &str) -> bool {
3597    let mac_upper = mac.to_ascii_uppercase();
3598    if mac_upper == "FF-FF-FF-FF-FF-FF" || mac_upper.starts_with("01-00-5E-") {
3599        return true;
3600    }
3601
3602    ip == "255.255.255.255"
3603        || ip.starts_with("224.")
3604        || ip.starts_with("225.")
3605        || ip.starts_with("226.")
3606        || ip.starts_with("227.")
3607        || ip.starts_with("228.")
3608        || ip.starts_with("229.")
3609        || ip.starts_with("230.")
3610        || ip.starts_with("231.")
3611        || ip.starts_with("232.")
3612        || ip.starts_with("233.")
3613        || ip.starts_with("234.")
3614        || ip.starts_with("235.")
3615        || ip.starts_with("236.")
3616        || ip.starts_with("237.")
3617        || ip.starts_with("238.")
3618        || ip.starts_with("239.")
3619}
3620
3621fn dedup_vec(values: &mut Vec<String>) {
3622    let mut seen = HashSet::new();
3623    values.retain(|value| seen.insert(value.clone()));
3624}
3625
3626#[cfg(target_os = "windows")]
3627fn parse_lan_neighbors(text: &str) -> Vec<(String, String, String, String)> {
3628    let trimmed = text.trim();
3629    if trimmed.is_empty() {
3630        return Vec::new();
3631    }
3632
3633    let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
3634        return Vec::new();
3635    };
3636    let entries = match value {
3637        Value::Array(items) => items,
3638        other => vec![other],
3639    };
3640
3641    let mut neighbors = Vec::new();
3642    for entry in entries {
3643        let ip = entry
3644            .get("IPAddress")
3645            .and_then(|v| v.as_str())
3646            .unwrap_or("")
3647            .to_string();
3648        if ip.is_empty() {
3649            continue;
3650        }
3651        let mac = entry
3652            .get("LinkLayerAddress")
3653            .and_then(|v| v.as_str())
3654            .unwrap_or("unknown")
3655            .to_string();
3656        let state = entry
3657            .get("State")
3658            .and_then(|v| v.as_str())
3659            .unwrap_or("unknown")
3660            .to_string();
3661        let iface = entry
3662            .get("InterfaceAlias")
3663            .and_then(|v| v.as_str())
3664            .unwrap_or("unknown")
3665            .to_string();
3666        if is_noise_lan_neighbor(&ip, &mac) {
3667            continue;
3668        }
3669        neighbors.push((ip, mac, state, iface));
3670    }
3671
3672    neighbors
3673}
3674
3675#[cfg(target_os = "windows")]
3676fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3677    let trimmed = text.trim();
3678    if trimmed.is_empty() {
3679        return Ok(Vec::new());
3680    }
3681
3682    let value: Value = serde_json::from_str(trimmed)
3683        .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3684    let entries = match value {
3685        Value::Array(items) => items,
3686        other => vec![other],
3687    };
3688
3689    let mut services = Vec::new();
3690    for entry in entries {
3691        let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3692            continue;
3693        };
3694        services.push(ServiceEntry {
3695            name: name.to_string(),
3696            status: entry
3697                .get("State")
3698                .and_then(|v| v.as_str())
3699                .unwrap_or("unknown")
3700                .to_string(),
3701            startup: entry
3702                .get("StartMode")
3703                .and_then(|v| v.as_str())
3704                .map(|v| v.to_string()),
3705            display_name: entry
3706                .get("DisplayName")
3707                .and_then(|v| v.as_str())
3708                .map(|v| v.to_string()),
3709            start_name: entry
3710                .get("StartName")
3711                .and_then(|v| v.as_str())
3712                .map(|v| v.to_string()),
3713        });
3714    }
3715
3716    Ok(services)
3717}
3718
3719#[cfg(target_os = "windows")]
3720fn windows_json_entries(node: Option<&Value>) -> Vec<Value> {
3721    match node.cloned() {
3722        Some(Value::Array(items)) => items,
3723        Some(other) => vec![other],
3724        None => Vec::new(),
3725    }
3726}
3727
3728#[cfg(target_os = "windows")]
3729fn parse_windows_pnp_devices(node: Option<&Value>) -> Vec<WindowsPnpDevice> {
3730    windows_json_entries(node)
3731        .into_iter()
3732        .filter_map(|entry| {
3733            let name = entry
3734                .get("FriendlyName")
3735                .and_then(|v| v.as_str())
3736                .or_else(|| entry.get("Name").and_then(|v| v.as_str()))
3737                .unwrap_or("")
3738                .trim()
3739                .to_string();
3740            if name.is_empty() {
3741                return None;
3742            }
3743            Some(WindowsPnpDevice {
3744                name,
3745                status: entry
3746                    .get("Status")
3747                    .and_then(|v| v.as_str())
3748                    .unwrap_or("Unknown")
3749                    .trim()
3750                    .to_string(),
3751                problem: entry.get("Problem").and_then(|v| v.as_u64()).or_else(|| {
3752                    entry
3753                        .get("Problem")
3754                        .and_then(|v| v.as_i64())
3755                        .map(|v| v as u64)
3756                }),
3757                class_name: entry
3758                    .get("Class")
3759                    .and_then(|v| v.as_str())
3760                    .map(|v| v.trim().to_string()),
3761                instance_id: entry
3762                    .get("InstanceId")
3763                    .and_then(|v| v.as_str())
3764                    .map(|v| v.trim().to_string()),
3765            })
3766        })
3767        .collect()
3768}
3769
3770#[cfg(target_os = "windows")]
3771fn parse_windows_sound_devices(node: Option<&Value>) -> Vec<WindowsSoundDevice> {
3772    windows_json_entries(node)
3773        .into_iter()
3774        .filter_map(|entry| {
3775            let name = entry
3776                .get("Name")
3777                .and_then(|v| v.as_str())
3778                .unwrap_or("")
3779                .trim()
3780                .to_string();
3781            if name.is_empty() {
3782                return None;
3783            }
3784            Some(WindowsSoundDevice {
3785                name,
3786                status: entry
3787                    .get("Status")
3788                    .and_then(|v| v.as_str())
3789                    .unwrap_or("Unknown")
3790                    .trim()
3791                    .to_string(),
3792                manufacturer: entry
3793                    .get("Manufacturer")
3794                    .and_then(|v| v.as_str())
3795                    .map(|v| v.trim().to_string()),
3796            })
3797        })
3798        .collect()
3799}
3800
3801#[cfg(target_os = "windows")]
3802fn windows_device_has_issue(device: &WindowsPnpDevice) -> bool {
3803    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
3804        || device.problem.unwrap_or(0) != 0
3805}
3806
3807#[cfg(target_os = "windows")]
3808fn windows_sound_device_has_issue(device: &WindowsSoundDevice) -> bool {
3809    !device.status.eq_ignore_ascii_case("ok") && !device.status.eq_ignore_ascii_case("unknown")
3810}
3811
3812#[cfg(target_os = "windows")]
3813fn is_microphone_like_name(name: &str) -> bool {
3814    let lower = name.to_ascii_lowercase();
3815    lower.contains("microphone")
3816        || lower.contains("mic")
3817        || lower.contains("input")
3818        || lower.contains("array")
3819        || lower.contains("capture")
3820        || lower.contains("record")
3821}
3822
3823#[cfg(target_os = "windows")]
3824fn is_bluetooth_like_name(name: &str) -> bool {
3825    let lower = name.to_ascii_lowercase();
3826    lower.contains("bluetooth") || lower.contains("hands-free") || lower.contains("a2dp")
3827}
3828
3829#[cfg(target_os = "windows")]
3830fn service_is_running(service: &ServiceEntry) -> bool {
3831    service.status.eq_ignore_ascii_case("running") || service.status.eq_ignore_ascii_case("active")
3832}
3833
3834#[cfg(not(target_os = "windows"))]
3835fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
3836    let mut startup_modes = std::collections::HashMap::<String, String>::new();
3837    for line in startup_text.lines() {
3838        let cols: Vec<&str> = line.split_whitespace().collect();
3839        if cols.len() < 2 {
3840            continue;
3841        }
3842        startup_modes.insert(cols[0].to_string(), cols[1].to_string());
3843    }
3844
3845    let mut services = Vec::new();
3846    for line in status_text.lines() {
3847        let cols: Vec<&str> = line.split_whitespace().collect();
3848        if cols.len() < 4 {
3849            continue;
3850        }
3851        let unit = cols[0];
3852        let load = cols[1];
3853        let active = cols[2];
3854        let sub = cols[3];
3855        let description = if cols.len() > 4 {
3856            Some(cols[4..].join(" "))
3857        } else {
3858            None
3859        };
3860        services.push(ServiceEntry {
3861            name: unit.to_string(),
3862            status: format!("{}/{}", active, sub),
3863            startup: startup_modes
3864                .get(unit)
3865                .cloned()
3866                .or_else(|| Some(load.to_string())),
3867            display_name: description,
3868            start_name: None,
3869        });
3870    }
3871
3872    services
3873}
3874
3875// ── health_report ─────────────────────────────────────────────────────────────
3876
3877/// Synthesized system health report — runs multiple checks and returns a
3878/// plain-English tiered verdict suitable for both developers and non-technical
3879/// users who just want to know if their machine is okay.
3880fn inspect_health_report() -> Result<String, String> {
3881    let mut needs_fix: Vec<String> = Vec::new();
3882    let mut watch: Vec<String> = Vec::new();
3883    let mut good: Vec<String> = Vec::new();
3884    let mut tips: Vec<String> = Vec::new();
3885
3886    health_check_disk(&mut needs_fix, &mut watch, &mut good);
3887    health_check_memory(&mut watch, &mut good);
3888    health_check_tools(&mut watch, &mut good, &mut tips);
3889    health_check_recent_errors(&mut watch, &mut tips);
3890
3891    let overall = if !needs_fix.is_empty() {
3892        "ACTION REQUIRED"
3893    } else if !watch.is_empty() {
3894        "WORTH A LOOK"
3895    } else {
3896        "ALL GOOD"
3897    };
3898
3899    let mut out = format!("System Health Report — {overall}\n\n");
3900
3901    if !needs_fix.is_empty() {
3902        out.push_str("Needs fixing:\n");
3903        for item in &needs_fix {
3904            out.push_str(&format!("  [!] {item}\n"));
3905        }
3906        out.push('\n');
3907    }
3908    if !watch.is_empty() {
3909        out.push_str("Worth watching:\n");
3910        for item in &watch {
3911            out.push_str(&format!("  [-] {item}\n"));
3912        }
3913        out.push('\n');
3914    }
3915    if !good.is_empty() {
3916        out.push_str("Looking good:\n");
3917        for item in &good {
3918            out.push_str(&format!("  [+] {item}\n"));
3919        }
3920        out.push('\n');
3921    }
3922    if !tips.is_empty() {
3923        out.push_str("To dig deeper:\n");
3924        for tip in &tips {
3925            out.push_str(&format!("  {tip}\n"));
3926        }
3927    }
3928
3929    Ok(out.trim_end().to_string())
3930}
3931
3932fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
3933    #[cfg(target_os = "windows")]
3934    {
3935        let script = r#"try {
3936    $d = Get-PSDrive C -ErrorAction Stop
3937    "$($d.Free)|$($d.Used)"
3938} catch { "ERR" }"#;
3939        if let Ok(out) = Command::new("powershell")
3940            .args(["-NoProfile", "-Command", script])
3941            .output()
3942        {
3943            let text = String::from_utf8_lossy(&out.stdout);
3944            let text = text.trim();
3945            if !text.starts_with("ERR") {
3946                let parts: Vec<&str> = text.split('|').collect();
3947                if parts.len() == 2 {
3948                    let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
3949                    let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
3950                    let total = free_bytes + used_bytes;
3951                    let free_gb = free_bytes / 1_073_741_824;
3952                    let pct_free = if total > 0 {
3953                        (free_bytes as f64 / total as f64 * 100.0) as u64
3954                    } else {
3955                        0
3956                    };
3957                    let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
3958                    if free_gb < 5 {
3959                        needs_fix.push(format!(
3960                            "{msg} — very low. Free up space or your system may slow down or stop working."
3961                        ));
3962                    } else if free_gb < 15 {
3963                        watch.push(format!("{msg} — getting low, consider cleaning up."));
3964                    } else {
3965                        good.push(msg);
3966                    }
3967                    return;
3968                }
3969            }
3970        }
3971        watch.push("Disk: could not read free space from C: drive.".to_string());
3972    }
3973
3974    #[cfg(not(target_os = "windows"))]
3975    {
3976        if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
3977            let text = String::from_utf8_lossy(&out.stdout);
3978            for line in text.lines().skip(1) {
3979                let cols: Vec<&str> = line.split_whitespace().collect();
3980                if cols.len() >= 5 {
3981                    let avail_str = cols[3].trim_end_matches('G');
3982                    let use_pct = cols[4].trim_end_matches('%');
3983                    let avail_gb: u64 = avail_str.parse().unwrap_or(0);
3984                    let used_pct: u64 = use_pct.parse().unwrap_or(0);
3985                    let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
3986                    if avail_gb < 5 {
3987                        needs_fix.push(format!(
3988                            "{msg} — very low. Free up space to prevent system issues."
3989                        ));
3990                    } else if avail_gb < 15 {
3991                        watch.push(format!("{msg} — getting low."));
3992                    } else {
3993                        good.push(msg);
3994                    }
3995                    return;
3996                }
3997            }
3998        }
3999        watch.push("Disk: could not determine free space.".to_string());
4000    }
4001}
4002
4003fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
4004    #[cfg(target_os = "windows")]
4005    {
4006        let script = r#"try {
4007    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
4008    "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
4009} catch { "ERR" }"#;
4010        if let Ok(out) = Command::new("powershell")
4011            .args(["-NoProfile", "-Command", script])
4012            .output()
4013        {
4014            let text = String::from_utf8_lossy(&out.stdout);
4015            let text = text.trim();
4016            if !text.starts_with("ERR") {
4017                let parts: Vec<&str> = text.split('|').collect();
4018                if parts.len() == 2 {
4019                    let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
4020                    let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
4021                    if total_kb > 0 {
4022                        let free_gb = free_kb / 1_048_576;
4023                        let total_gb = total_kb / 1_048_576;
4024                        let free_pct = free_kb * 100 / total_kb;
4025                        let msg = format!(
4026                            "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
4027                        );
4028                        if free_pct < 10 {
4029                            watch.push(format!(
4030                                "{msg} — very low. Close unused apps to free up memory."
4031                            ));
4032                        } else if free_pct < 25 {
4033                            watch.push(format!("{msg} — running a bit low."));
4034                        } else {
4035                            good.push(msg);
4036                        }
4037                        return;
4038                    }
4039                }
4040            }
4041        }
4042    }
4043
4044    #[cfg(not(target_os = "windows"))]
4045    {
4046        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4047            let mut total_kb = 0u64;
4048            let mut avail_kb = 0u64;
4049            for line in content.lines() {
4050                if line.starts_with("MemTotal:") {
4051                    total_kb = line
4052                        .split_whitespace()
4053                        .nth(1)
4054                        .and_then(|v| v.parse().ok())
4055                        .unwrap_or(0);
4056                } else if line.starts_with("MemAvailable:") {
4057                    avail_kb = line
4058                        .split_whitespace()
4059                        .nth(1)
4060                        .and_then(|v| v.parse().ok())
4061                        .unwrap_or(0);
4062                }
4063            }
4064            if total_kb > 0 {
4065                let free_gb = avail_kb / 1_048_576;
4066                let total_gb = total_kb / 1_048_576;
4067                let free_pct = avail_kb * 100 / total_kb;
4068                let msg =
4069                    format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
4070                if free_pct < 10 {
4071                    watch.push(format!("{msg} — very low. Close unused apps."));
4072                } else if free_pct < 25 {
4073                    watch.push(format!("{msg} — running a bit low."));
4074                } else {
4075                    good.push(msg);
4076                }
4077            }
4078        }
4079    }
4080}
4081
4082fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
4083    let tool_checks: &[(&str, &str, &str)] = &[
4084        ("git", "--version", "Git"),
4085        ("cargo", "--version", "Rust / Cargo"),
4086        ("node", "--version", "Node.js"),
4087        ("python", "--version", "Python"),
4088        ("python3", "--version", "Python 3"),
4089        ("npm", "--version", "npm"),
4090    ];
4091
4092    let mut found: Vec<String> = Vec::new();
4093    let mut missing: Vec<String> = Vec::new();
4094    let mut python_found = false;
4095
4096    for (cmd, arg, label) in tool_checks {
4097        if cmd.starts_with("python") && python_found {
4098            continue;
4099        }
4100        let ok = Command::new(cmd)
4101            .arg(arg)
4102            .stdout(std::process::Stdio::null())
4103            .stderr(std::process::Stdio::null())
4104            .status()
4105            .map(|s| s.success())
4106            .unwrap_or(false);
4107        if ok {
4108            found.push((*label).to_string());
4109            if cmd.starts_with("python") {
4110                python_found = true;
4111            }
4112        } else if !cmd.starts_with("python") || !python_found {
4113            missing.push((*label).to_string());
4114        }
4115    }
4116
4117    if !found.is_empty() {
4118        good.push(format!("Dev tools found: {}", found.join(", ")));
4119    }
4120    if !missing.is_empty() {
4121        watch.push(format!(
4122            "Not installed (or not on PATH): {} — only matters if you need them",
4123            missing.join(", ")
4124        ));
4125        tips.push(
4126            "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
4127                .to_string(),
4128        );
4129    }
4130}
4131
4132fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
4133    #[cfg(target_os = "windows")]
4134    {
4135        let script = r#"try {
4136    $cutoff = (Get-Date).AddHours(-24)
4137    $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
4138    $count
4139} catch { "0" }"#;
4140        if let Ok(out) = Command::new("powershell")
4141            .args(["-NoProfile", "-Command", script])
4142            .output()
4143        {
4144            let text = String::from_utf8_lossy(&out.stdout);
4145            let count: u64 = text.trim().parse().unwrap_or(0);
4146            if count > 0 {
4147                watch.push(format!(
4148                    "{count} critical/error event{} in Windows event logs in the last 24 hours.",
4149                    if count == 1 { "" } else { "s" }
4150                ));
4151                tips.push(
4152                    "Run inspect_host(topic=\"log_check\") to see the actual error messages."
4153                        .to_string(),
4154                );
4155            }
4156        }
4157    }
4158
4159    #[cfg(not(target_os = "windows"))]
4160    {
4161        if let Ok(out) = Command::new("journalctl")
4162            .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
4163            .output()
4164        {
4165            let text = String::from_utf8_lossy(&out.stdout);
4166            if !text.trim().is_empty() {
4167                watch.push("Critical/error entries found in the system journal.".to_string());
4168                tips.push(
4169                    "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
4170                );
4171            }
4172        }
4173    }
4174}
4175
4176// ── log_check ─────────────────────────────────────────────────────────────────
4177
4178fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
4179    let mut out = String::from("Host inspection: log_check\n\n");
4180
4181    #[cfg(target_os = "windows")]
4182    {
4183        // Pull recent critical/error events from Windows Application and System logs.
4184        let hours = lookback_hours.unwrap_or(24);
4185        out.push_str(&format!(
4186            "Checking System/Application logs from the last {} hours...\n\n",
4187            hours
4188        ));
4189
4190        let n = max_entries.clamp(1, 50);
4191        let script = format!(
4192            r#"try {{
4193    $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
4194    if (-not $events) {{ "NO_EVENTS"; exit }}
4195    $events | Select-Object -First {n} | ForEach-Object {{
4196        $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
4197        $line
4198    }}
4199}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
4200            hours = hours,
4201            n = n
4202        );
4203        let output = Command::new("powershell")
4204            .args(["-NoProfile", "-Command", &script])
4205            .output()
4206            .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
4207
4208        let raw = String::from_utf8_lossy(&output.stdout);
4209        let text = raw.trim();
4210
4211        if text.is_empty() || text == "NO_EVENTS" {
4212            out.push_str("No critical or error events found in Application/System logs.\n");
4213            return Ok(out.trim_end().to_string());
4214        }
4215        if text.starts_with("ERROR:") {
4216            out.push_str(&format!("Warning: event log query returned: {text}\n"));
4217            return Ok(out.trim_end().to_string());
4218        }
4219
4220        let mut count = 0usize;
4221        for line in text.lines() {
4222            let parts: Vec<&str> = line.splitn(4, '|').collect();
4223            if parts.len() == 4 {
4224                let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
4225                out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
4226                count += 1;
4227            }
4228        }
4229        out.push_str(&format!(
4230            "\nEvents shown: {count} (critical/error from Application + System logs)\n"
4231        ));
4232    }
4233
4234    #[cfg(not(target_os = "windows"))]
4235    {
4236        let _ = lookback_hours;
4237        // Use journalctl on Linux/macOS if available.
4238        let n = max_entries.clamp(1, 50).to_string();
4239        let output = Command::new("journalctl")
4240            .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
4241            .output();
4242
4243        match output {
4244            Ok(o) if o.status.success() => {
4245                let text = String::from_utf8_lossy(&o.stdout);
4246                let trimmed = text.trim();
4247                if trimmed.is_empty() || trimmed.contains("No entries") {
4248                    out.push_str("No critical or error entries found in the system journal.\n");
4249                } else {
4250                    out.push_str(trimmed);
4251                    out.push('\n');
4252                    out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
4253                }
4254            }
4255            _ => {
4256                // Fallback: check /var/log/syslog or /var/log/messages
4257                let log_paths = ["/var/log/syslog", "/var/log/messages"];
4258                let mut found = false;
4259                for log_path in &log_paths {
4260                    if let Ok(content) = std::fs::read_to_string(log_path) {
4261                        let lines: Vec<&str> = content.lines().collect();
4262                        let tail: Vec<&str> = lines
4263                            .iter()
4264                            .rev()
4265                            .filter(|l| {
4266                                let l_lower = l.to_ascii_lowercase();
4267                                l_lower.contains("error") || l_lower.contains("crit")
4268                            })
4269                            .take(max_entries)
4270                            .copied()
4271                            .collect::<Vec<_>>()
4272                            .into_iter()
4273                            .rev()
4274                            .collect();
4275                        if !tail.is_empty() {
4276                            out.push_str(&format!("Source: {log_path}\n"));
4277                            for l in &tail {
4278                                out.push_str(l);
4279                                out.push('\n');
4280                            }
4281                            found = true;
4282                            break;
4283                        }
4284                    }
4285                }
4286                if !found {
4287                    out.push_str(
4288                        "journalctl not found and no readable syslog detected on this system.\n",
4289                    );
4290                }
4291            }
4292        }
4293    }
4294
4295    Ok(out.trim_end().to_string())
4296}
4297
4298// ── startup_items ─────────────────────────────────────────────────────────────
4299
4300fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
4301    let mut out = String::from("Host inspection: startup_items\n\n");
4302
4303    #[cfg(target_os = "windows")]
4304    {
4305        // Query both HKLM and HKCU Run keys.
4306        let script = r#"
4307$hives = @(
4308    @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4309    @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
4310    @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
4311)
4312foreach ($h in $hives) {
4313    try {
4314        $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
4315        $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
4316            "$($h.Hive)|$($_.Name)|$($_.Value)"
4317        }
4318    } catch {}
4319}
4320"#;
4321        let output = Command::new("powershell")
4322            .args(["-NoProfile", "-Command", script])
4323            .output()
4324            .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
4325
4326        let raw = String::from_utf8_lossy(&output.stdout);
4327        let text = raw.trim();
4328
4329        let entries: Vec<(String, String, String)> = text
4330            .lines()
4331            .filter_map(|l| {
4332                let parts: Vec<&str> = l.splitn(3, '|').collect();
4333                if parts.len() == 3 {
4334                    Some((
4335                        parts[0].to_string(),
4336                        parts[1].to_string(),
4337                        parts[2].to_string(),
4338                    ))
4339                } else {
4340                    None
4341                }
4342            })
4343            .take(max_entries)
4344            .collect();
4345
4346        if entries.is_empty() {
4347            out.push_str("No startup entries found in the Windows Run registry keys.\n");
4348        } else {
4349            out.push_str("Registry run keys (programs that start with Windows):\n\n");
4350            let mut last_hive = String::new();
4351            for (hive, name, value) in &entries {
4352                if *hive != last_hive {
4353                    out.push_str(&format!("[{}]\n", hive));
4354                    last_hive = hive.clone();
4355                }
4356                // Truncate very long values (paths with many args)
4357                let display = if value.len() > 100 {
4358                    format!("{}…", &value[..100])
4359                } else {
4360                    value.clone()
4361                };
4362                out.push_str(&format!("  {name}: {display}\n"));
4363            }
4364            out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
4365        }
4366
4367        // 3. Unified Startup Command check (Task Manager style)
4368        let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { "  $($_.Name): $($_.Command) ($($_.Location))" }"#;
4369        if let Ok(unified_out) = Command::new("powershell")
4370            .args(["-NoProfile", "-Command", unified_script])
4371            .output()
4372        {
4373            let unified_text = String::from_utf8_lossy(&unified_out.stdout);
4374            let trimmed = unified_text.trim();
4375            if !trimmed.is_empty() {
4376                out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
4377                out.push_str(trimmed);
4378                out.push('\n');
4379            }
4380        }
4381    }
4382
4383    #[cfg(not(target_os = "windows"))]
4384    {
4385        // On Linux: systemd enabled services + cron @reboot entries.
4386        let output = Command::new("systemctl")
4387            .args([
4388                "list-unit-files",
4389                "--type=service",
4390                "--state=enabled",
4391                "--no-legend",
4392                "--no-pager",
4393                "--plain",
4394            ])
4395            .output();
4396
4397        match output {
4398            Ok(o) if o.status.success() => {
4399                let text = String::from_utf8_lossy(&o.stdout);
4400                let services: Vec<&str> = text
4401                    .lines()
4402                    .filter(|l| !l.trim().is_empty())
4403                    .take(max_entries)
4404                    .collect();
4405                if services.is_empty() {
4406                    out.push_str("No enabled systemd services found.\n");
4407                } else {
4408                    out.push_str("Enabled systemd services (run at boot):\n\n");
4409                    for s in &services {
4410                        out.push_str(&format!("  {s}\n"));
4411                    }
4412                    out.push_str(&format!(
4413                        "\nShowing {} of enabled services.\n",
4414                        services.len()
4415                    ));
4416                }
4417            }
4418            _ => {
4419                out.push_str(
4420                    "systemctl not found on this system. Cannot enumerate startup services.\n",
4421                );
4422            }
4423        }
4424
4425        // Check @reboot cron entries.
4426        if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
4427            let cron_text = String::from_utf8_lossy(&cron_out.stdout);
4428            let reboot_entries: Vec<&str> = cron_text
4429                .lines()
4430                .filter(|l| l.trim_start().starts_with("@reboot"))
4431                .collect();
4432            if !reboot_entries.is_empty() {
4433                out.push_str("\nCron @reboot entries:\n");
4434                for e in reboot_entries {
4435                    out.push_str(&format!("  {e}\n"));
4436                }
4437            }
4438        }
4439    }
4440
4441    Ok(out.trim_end().to_string())
4442}
4443
4444fn inspect_os_config() -> Result<String, String> {
4445    let mut out = String::from("Host inspection: OS Configuration\n\n");
4446
4447    #[cfg(target_os = "windows")]
4448    {
4449        // Power Plan
4450        if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
4451            let power_str = String::from_utf8_lossy(&power_out.stdout);
4452            out.push_str("=== Power Plan ===\n");
4453            out.push_str(power_str.trim());
4454            out.push_str("\n\n");
4455        }
4456
4457        // Firewall Status
4458        let fw_script =
4459            "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
4460        if let Ok(fw_out) = Command::new("powershell")
4461            .args(["-NoProfile", "-Command", fw_script])
4462            .output()
4463        {
4464            let fw_str = String::from_utf8_lossy(&fw_out.stdout);
4465            out.push_str("=== Firewall Profiles ===\n");
4466            out.push_str(fw_str.trim());
4467            out.push_str("\n\n");
4468        }
4469
4470        // System Uptime
4471        let uptime_script =
4472            "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
4473        if let Ok(uptime_out) = Command::new("powershell")
4474            .args(["-NoProfile", "-Command", uptime_script])
4475            .output()
4476        {
4477            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4478            out.push_str("=== System Uptime (Last Boot) ===\n");
4479            out.push_str(uptime_str.trim());
4480            out.push_str("\n\n");
4481        }
4482    }
4483
4484    #[cfg(not(target_os = "windows"))]
4485    {
4486        // Uptime
4487        if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
4488            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
4489            out.push_str("=== System Uptime ===\n");
4490            out.push_str(uptime_str.trim());
4491            out.push_str("\n\n");
4492        }
4493
4494        // Firewall (ufw status if available)
4495        if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
4496            let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
4497            if !ufw_str.trim().is_empty() {
4498                out.push_str("=== Firewall (UFW) ===\n");
4499                out.push_str(ufw_str.trim());
4500                out.push_str("\n\n");
4501            }
4502        }
4503    }
4504    Ok(out.trim_end().to_string())
4505}
4506
4507pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
4508    let action = args
4509        .get("action")
4510        .and_then(|v| v.as_str())
4511        .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
4512
4513    let target = args
4514        .get("target")
4515        .and_then(|v| v.as_str())
4516        .unwrap_or("")
4517        .trim();
4518
4519    if target.is_empty() && action != "clear_temp" {
4520        return Err("Missing required argument: 'target' for this action".to_string());
4521    }
4522
4523    match action {
4524        "install_package" => {
4525            #[cfg(target_os = "windows")]
4526            {
4527                let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
4528                match Command::new("powershell")
4529                    .args(["-NoProfile", "-Command", &cmd])
4530                    .output()
4531                {
4532                    Ok(out) => Ok(format!(
4533                        "Executed remediation (winget install):\n{}",
4534                        String::from_utf8_lossy(&out.stdout)
4535                    )),
4536                    Err(e) => Err(format!("Failed to run winget: {}", e)),
4537                }
4538            }
4539            #[cfg(not(target_os = "windows"))]
4540            {
4541                Err(
4542                    "install_package via wrapper is only supported on Windows currently (winget)"
4543                        .to_string(),
4544                )
4545            }
4546        }
4547        "restart_service" => {
4548            #[cfg(target_os = "windows")]
4549            {
4550                let cmd = format!("Restart-Service -Name {} -Force", target);
4551                match Command::new("powershell")
4552                    .args(["-NoProfile", "-Command", &cmd])
4553                    .output()
4554                {
4555                    Ok(out) => {
4556                        let err_str = String::from_utf8_lossy(&out.stderr);
4557                        if !err_str.is_empty() {
4558                            return Err(format!("Error restarting service:\n{}", err_str));
4559                        }
4560                        Ok(format!("Successfully restarted service: {}", target))
4561                    }
4562                    Err(e) => Err(format!("Failed to restart service: {}", e)),
4563                }
4564            }
4565            #[cfg(not(target_os = "windows"))]
4566            {
4567                Err(
4568                    "restart_service via wrapper is only supported on Windows currently"
4569                        .to_string(),
4570                )
4571            }
4572        }
4573        "clear_temp" => {
4574            #[cfg(target_os = "windows")]
4575            {
4576                let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
4577                match Command::new("powershell")
4578                    .args(["-NoProfile", "-Command", cmd])
4579                    .output()
4580                {
4581                    Ok(_) => Ok("Successfully cleared temporary files".to_string()),
4582                    Err(e) => Err(format!("Failed to clear temp: {}", e)),
4583                }
4584            }
4585            #[cfg(not(target_os = "windows"))]
4586            {
4587                Err("clear_temp via wrapper is only supported on Windows currently".to_string())
4588            }
4589        }
4590        other => Err(format!("Unknown remediation action: {}", other)),
4591    }
4592}
4593
4594// ── storage ───────────────────────────────────────────────────────────────────
4595
4596fn inspect_storage(max_entries: usize) -> Result<String, String> {
4597    let mut out = String::from("Host inspection: storage\n\n");
4598    let _ = max_entries; // used by non-Windows branch
4599
4600    // ── Drive overview ────────────────────────────────────────────────────────
4601    out.push_str("Drives:\n");
4602
4603    #[cfg(target_os = "windows")]
4604    {
4605        let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
4606    $free = $_.Free
4607    $used = $_.Used
4608    if ($free -eq $null) { $free = 0 }
4609    if ($used -eq $null) { $used = 0 }
4610    $total = $free + $used
4611    "$($_.Name)|$free|$used|$total"
4612}"#;
4613        match Command::new("powershell")
4614            .args(["-NoProfile", "-Command", script])
4615            .output()
4616        {
4617            Ok(o) => {
4618                let text = String::from_utf8_lossy(&o.stdout);
4619                let mut drive_count = 0usize;
4620                for line in text.lines() {
4621                    let parts: Vec<&str> = line.trim().split('|').collect();
4622                    if parts.len() == 4 {
4623                        let name = parts[0];
4624                        let free: u64 = parts[1].parse().unwrap_or(0);
4625                        let total: u64 = parts[3].parse().unwrap_or(0);
4626                        if total == 0 {
4627                            continue;
4628                        }
4629                        let free_gb = free / 1_073_741_824;
4630                        let total_gb = total / 1_073_741_824;
4631                        let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
4632                        let bar_len = 20usize;
4633                        let filled = (used_pct as usize * bar_len / 100).min(bar_len);
4634                        let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
4635                        let warn = if free_gb < 5 {
4636                            " [!] CRITICALLY LOW"
4637                        } else if free_gb < 15 {
4638                            " [-] LOW"
4639                        } else {
4640                            ""
4641                        };
4642                        out.push_str(&format!(
4643                            "  {name}:  [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
4644                        ));
4645                        drive_count += 1;
4646                    }
4647                }
4648                if drive_count == 0 {
4649                    out.push_str("  (could not enumerate drives)\n");
4650                }
4651            }
4652            Err(e) => out.push_str(&format!("  (drive scan failed: {e})\n")),
4653        }
4654
4655        // ── Real-time Performance (Latency) ──────────────────────────────────
4656        let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
4657        match Command::new("powershell")
4658            .args(["-NoProfile", "-Command", latency_script])
4659            .output()
4660        {
4661            Ok(o) => {
4662                out.push_str("\nReal-time Disk Intensity:\n");
4663                let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
4664                if !text.is_empty() {
4665                    out.push_str(&format!("  Average Disk Queue Length: {text}\n"));
4666                    if let Ok(q) = text.parse::<f64>() {
4667                        if q > 2.0 {
4668                            out.push_str(
4669                                "  [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
4670                            );
4671                        } else {
4672                            out.push_str("  [~] Disk latency is within healthy bounds.\n");
4673                        }
4674                    }
4675                } else {
4676                    out.push_str("  Average Disk Queue Length: unavailable\n");
4677                }
4678            }
4679            Err(_) => {
4680                out.push_str("\nReal-time Disk Intensity:\n");
4681                out.push_str("  Average Disk Queue Length: unavailable\n");
4682            }
4683        }
4684    }
4685
4686    #[cfg(not(target_os = "windows"))]
4687    {
4688        match Command::new("df")
4689            .args(["-h", "--output=target,size,avail,pcent"])
4690            .output()
4691        {
4692            Ok(o) => {
4693                let text = String::from_utf8_lossy(&o.stdout);
4694                let mut count = 0usize;
4695                for line in text.lines().skip(1) {
4696                    let cols: Vec<&str> = line.split_whitespace().collect();
4697                    if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
4698                        out.push_str(&format!(
4699                            "  {}  size: {}  avail: {}  used: {}\n",
4700                            cols[0], cols[1], cols[2], cols[3]
4701                        ));
4702                        count += 1;
4703                        if count >= max_entries {
4704                            break;
4705                        }
4706                    }
4707                }
4708            }
4709            Err(e) => out.push_str(&format!("  (df failed: {e})\n")),
4710        }
4711    }
4712
4713    // ── Large developer cache directories ─────────────────────────────────────
4714    out.push_str("\nLarge developer cache directories (if present):\n");
4715
4716    #[cfg(target_os = "windows")]
4717    {
4718        let home = std::env::var("USERPROFILE").unwrap_or_default();
4719        let check_dirs: &[(&str, &str)] = &[
4720            ("Temp", r"AppData\Local\Temp"),
4721            ("npm cache", r"AppData\Roaming\npm-cache"),
4722            ("Cargo registry", r".cargo\registry"),
4723            ("Cargo git", r".cargo\git"),
4724            ("pip cache", r"AppData\Local\pip\cache"),
4725            ("Yarn cache", r"AppData\Local\Yarn\Cache"),
4726            (".rustup toolchains", r".rustup\toolchains"),
4727            ("node_modules (home)", r"node_modules"),
4728        ];
4729
4730        let mut found_any = false;
4731        for (label, rel) in check_dirs {
4732            let full = format!(r"{}\{}", home, rel);
4733            let path = std::path::Path::new(&full);
4734            if path.exists() {
4735                // Quick size estimate via PowerShell (non-blocking cap at 5s)
4736                let size_script = format!(
4737                    r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
4738                    full.replace('\'', "''")
4739                );
4740                let size_mb = Command::new("powershell")
4741                    .args(["-NoProfile", "-Command", &size_script])
4742                    .output()
4743                    .ok()
4744                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
4745                    .unwrap_or_else(|| "?".to_string());
4746                out.push_str(&format!("  {label}: {size_mb} MB  ({full})\n"));
4747                found_any = true;
4748            }
4749        }
4750        if !found_any {
4751            out.push_str("  (none of the common cache directories found)\n");
4752        }
4753
4754        out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
4755    }
4756
4757    #[cfg(not(target_os = "windows"))]
4758    {
4759        let home = std::env::var("HOME").unwrap_or_default();
4760        let check_dirs: &[(&str, &str)] = &[
4761            ("npm cache", ".npm"),
4762            ("Cargo registry", ".cargo/registry"),
4763            ("pip cache", ".cache/pip"),
4764            (".rustup toolchains", ".rustup/toolchains"),
4765            ("Yarn cache", ".cache/yarn"),
4766        ];
4767        let mut found_any = false;
4768        for (label, rel) in check_dirs {
4769            let full = format!("{}/{}", home, rel);
4770            if std::path::Path::new(&full).exists() {
4771                let size = Command::new("du")
4772                    .args(["-sh", &full])
4773                    .output()
4774                    .ok()
4775                    .map(|o| {
4776                        let s = String::from_utf8_lossy(&o.stdout);
4777                        s.split_whitespace().next().unwrap_or("?").to_string()
4778                    })
4779                    .unwrap_or_else(|| "?".to_string());
4780                out.push_str(&format!("  {label}: {size}  ({full})\n"));
4781                found_any = true;
4782            }
4783        }
4784        if !found_any {
4785            out.push_str("  (none of the common cache directories found)\n");
4786        }
4787    }
4788
4789    Ok(out.trim_end().to_string())
4790}
4791
4792// ── hardware ──────────────────────────────────────────────────────────────────
4793
4794fn inspect_hardware() -> Result<String, String> {
4795    let mut out = String::from("Host inspection: hardware\n\n");
4796
4797    #[cfg(target_os = "windows")]
4798    {
4799        // CPU
4800        let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
4801    "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
4802} | Select-Object -First 1"#;
4803        if let Ok(o) = Command::new("powershell")
4804            .args(["-NoProfile", "-Command", cpu_script])
4805            .output()
4806        {
4807            let text = String::from_utf8_lossy(&o.stdout);
4808            let text = text.trim();
4809            let parts: Vec<&str> = text.split('|').collect();
4810            if parts.len() == 4 {
4811                out.push_str(&format!(
4812                    "CPU: {}\n  {} physical cores, {} logical processors, {:.1} GHz\n\n",
4813                    parts[0],
4814                    parts[1],
4815                    parts[2],
4816                    parts[3].parse::<f32>().unwrap_or(0.0)
4817                ));
4818            } else {
4819                out.push_str(&format!("CPU: {text}\n\n"));
4820            }
4821        }
4822
4823        // RAM (total installed + speed)
4824        let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
4825$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
4826$speed = ($sticks | Select-Object -First 1).Speed
4827"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
4828        if let Ok(o) = Command::new("powershell")
4829            .args(["-NoProfile", "-Command", ram_script])
4830            .output()
4831        {
4832            let text = String::from_utf8_lossy(&o.stdout);
4833            out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
4834        }
4835
4836        // GPU(s)
4837        let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
4838    "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
4839}"#;
4840        if let Ok(o) = Command::new("powershell")
4841            .args(["-NoProfile", "-Command", gpu_script])
4842            .output()
4843        {
4844            let text = String::from_utf8_lossy(&o.stdout);
4845            let lines: Vec<&str> = text.lines().collect();
4846            if !lines.is_empty() {
4847                out.push_str("GPU(s):\n");
4848                for line in lines.iter().filter(|l| !l.trim().is_empty()) {
4849                    let parts: Vec<&str> = line.trim().split('|').collect();
4850                    if parts.len() == 3 {
4851                        let res = if parts[2] == "x" || parts[2].starts_with('0') {
4852                            String::new()
4853                        } else {
4854                            format!(" — {}@display", parts[2])
4855                        };
4856                        out.push_str(&format!(
4857                            "  {}\n    Driver: {}{}\n",
4858                            parts[0], parts[1], res
4859                        ));
4860                    } else {
4861                        out.push_str(&format!("  {}\n", line.trim()));
4862                    }
4863                }
4864                out.push('\n');
4865            }
4866        }
4867
4868        // Motherboard + BIOS + Virtualization
4869        let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
4870$bios = Get-CimInstance Win32_BIOS
4871$cs = Get-CimInstance Win32_ComputerSystem
4872$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
4873$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
4874"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
4875        if let Ok(o) = Command::new("powershell")
4876            .args(["-NoProfile", "-Command", mb_script])
4877            .output()
4878        {
4879            let text = String::from_utf8_lossy(&o.stdout);
4880            let text = text.trim().trim_matches('"');
4881            let parts: Vec<&str> = text.split('|').collect();
4882            if parts.len() == 4 {
4883                out.push_str(&format!(
4884                    "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
4885                    parts[0].trim(),
4886                    parts[1].trim(),
4887                    parts[2].trim(),
4888                    parts[3].trim()
4889                ));
4890            }
4891        }
4892
4893        // Display(s)
4894        let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
4895    "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
4896}"#;
4897        if let Ok(o) = Command::new("powershell")
4898            .args(["-NoProfile", "-Command", disp_script])
4899            .output()
4900        {
4901            let text = String::from_utf8_lossy(&o.stdout);
4902            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4903            if !lines.is_empty() {
4904                out.push_str("Display(s):\n");
4905                for line in &lines {
4906                    let parts: Vec<&str> = line.trim().split('|').collect();
4907                    if parts.len() == 2 {
4908                        out.push_str(&format!("  {} — {}\n", parts[0].trim(), parts[1]));
4909                    }
4910                }
4911            }
4912        }
4913    }
4914
4915    #[cfg(not(target_os = "windows"))]
4916    {
4917        // CPU via /proc/cpuinfo
4918        if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
4919            let model = content
4920                .lines()
4921                .find(|l| l.starts_with("model name"))
4922                .and_then(|l| l.split(':').nth(1))
4923                .map(str::trim)
4924                .unwrap_or("unknown");
4925            let cores = content
4926                .lines()
4927                .filter(|l| l.starts_with("processor"))
4928                .count();
4929            out.push_str(&format!("CPU: {model}\n  {cores} logical processors\n\n"));
4930        }
4931
4932        // RAM
4933        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4934            let total_kb: u64 = content
4935                .lines()
4936                .find(|l| l.starts_with("MemTotal:"))
4937                .and_then(|l| l.split_whitespace().nth(1))
4938                .and_then(|v| v.parse().ok())
4939                .unwrap_or(0);
4940            let total_gb = total_kb / 1_048_576;
4941            out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
4942        }
4943
4944        // GPU via lspci
4945        if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
4946            let text = String::from_utf8_lossy(&o.stdout);
4947            let gpu_lines: Vec<&str> = text
4948                .lines()
4949                .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
4950                .collect();
4951            if !gpu_lines.is_empty() {
4952                out.push_str("GPU(s):\n");
4953                for l in gpu_lines {
4954                    out.push_str(&format!("  {l}\n"));
4955                }
4956                out.push('\n');
4957            }
4958        }
4959
4960        // DMI/BIOS info
4961        if let Ok(o) = Command::new("dmidecode")
4962            .args(["-t", "baseboard", "-t", "bios"])
4963            .output()
4964        {
4965            let text = String::from_utf8_lossy(&o.stdout);
4966            out.push_str("Motherboard/BIOS:\n");
4967            for line in text
4968                .lines()
4969                .filter(|l| {
4970                    l.contains("Manufacturer:")
4971                        || l.contains("Product Name:")
4972                        || l.contains("Version:")
4973                })
4974                .take(6)
4975            {
4976                out.push_str(&format!("  {}\n", line.trim()));
4977            }
4978        }
4979    }
4980
4981    Ok(out.trim_end().to_string())
4982}
4983
4984// ── updates ───────────────────────────────────────────────────────────────────
4985
4986fn inspect_updates() -> Result<String, String> {
4987    let mut out = String::from("Host inspection: updates\n\n");
4988
4989    #[cfg(target_os = "windows")]
4990    {
4991        // Last installed update via COM
4992        let script = r#"
4993try {
4994    $sess = New-Object -ComObject Microsoft.Update.Session
4995    $searcher = $sess.CreateUpdateSearcher()
4996    $count = $searcher.GetTotalHistoryCount()
4997    if ($count -gt 0) {
4998        $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
4999        $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
5000    } else { "NONE|LAST_INSTALL" }
5001} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
5002"#;
5003        if let Ok(o) = Command::new("powershell")
5004            .args(["-NoProfile", "-Command", script])
5005            .output()
5006        {
5007            let raw = String::from_utf8_lossy(&o.stdout);
5008            let text = raw.trim();
5009            if text.starts_with("ERROR:") {
5010                out.push_str("Last update install: (unable to query)\n");
5011            } else if text.contains("NONE") {
5012                out.push_str("Last update install: No update history found\n");
5013            } else {
5014                let date = text.replace("|LAST_INSTALL", "");
5015                out.push_str(&format!("Last update install: {date}\n"));
5016            }
5017        }
5018
5019        // Pending updates count
5020        let pending_script = r#"
5021try {
5022    $sess = New-Object -ComObject Microsoft.Update.Session
5023    $searcher = $sess.CreateUpdateSearcher()
5024    $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
5025    $results.Updates.Count.ToString() + "|PENDING"
5026} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
5027"#;
5028        if let Ok(o) = Command::new("powershell")
5029            .args(["-NoProfile", "-Command", pending_script])
5030            .output()
5031        {
5032            let raw = String::from_utf8_lossy(&o.stdout);
5033            let text = raw.trim();
5034            if text.starts_with("ERROR:") {
5035                out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
5036            } else {
5037                let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
5038                if count == 0 {
5039                    out.push_str("Pending updates: Up to date — no updates waiting\n");
5040                } else if count > 0 {
5041                    out.push_str(&format!("Pending updates: {count} update(s) available\n"));
5042                    out.push_str(
5043                        "  → Open Windows Update (Settings > Windows Update) to install\n",
5044                    );
5045                }
5046            }
5047        }
5048
5049        // Windows Update service state
5050        let svc_script = r#"
5051$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
5052if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
5053"#;
5054        if let Ok(o) = Command::new("powershell")
5055            .args(["-NoProfile", "-Command", svc_script])
5056            .output()
5057        {
5058            let raw = String::from_utf8_lossy(&o.stdout);
5059            let status = raw.trim();
5060            out.push_str(&format!("Windows Update service: {status}\n"));
5061        }
5062    }
5063
5064    #[cfg(not(target_os = "windows"))]
5065    {
5066        let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
5067        let mut found = false;
5068        if let Ok(o) = apt_out {
5069            let text = String::from_utf8_lossy(&o.stdout);
5070            let lines: Vec<&str> = text
5071                .lines()
5072                .filter(|l| l.contains('/') && !l.contains("Listing"))
5073                .collect();
5074            if !lines.is_empty() {
5075                out.push_str(&format!(
5076                    "{} package(s) can be upgraded (apt)\n",
5077                    lines.len()
5078                ));
5079                out.push_str("  → Run: sudo apt upgrade\n");
5080                found = true;
5081            }
5082        }
5083        if !found {
5084            if let Ok(o) = Command::new("dnf")
5085                .args(["check-update", "--quiet"])
5086                .output()
5087            {
5088                let text = String::from_utf8_lossy(&o.stdout);
5089                let count = text
5090                    .lines()
5091                    .filter(|l| !l.is_empty() && !l.starts_with('!'))
5092                    .count();
5093                if count > 0 {
5094                    out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
5095                    out.push_str("  → Run: sudo dnf upgrade\n");
5096                } else {
5097                    out.push_str("System is up to date.\n");
5098                }
5099            } else {
5100                out.push_str("Could not query package manager for updates.\n");
5101            }
5102        }
5103    }
5104
5105    Ok(out.trim_end().to_string())
5106}
5107
5108// ── security ──────────────────────────────────────────────────────────────────
5109
5110fn inspect_security() -> Result<String, String> {
5111    let mut out = String::from("Host inspection: security\n\n");
5112
5113    #[cfg(target_os = "windows")]
5114    {
5115        // Windows Defender status
5116        let defender_script = r#"
5117try {
5118    $status = Get-MpComputerStatus -ErrorAction Stop
5119    "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
5120} catch { "ERROR:" + $_.Exception.Message }
5121"#;
5122        if let Ok(o) = Command::new("powershell")
5123            .args(["-NoProfile", "-Command", defender_script])
5124            .output()
5125        {
5126            let raw = String::from_utf8_lossy(&o.stdout);
5127            let text = raw.trim();
5128            if text.starts_with("ERROR:") {
5129                out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
5130            } else {
5131                let get = |key: &str| -> String {
5132                    text.split('|')
5133                        .find(|s| s.starts_with(key))
5134                        .and_then(|s| s.splitn(2, ':').nth(1))
5135                        .unwrap_or("unknown")
5136                        .to_string()
5137                };
5138                let rtp = get("RTP");
5139                let last_scan = {
5140                    // SCAN field has a colon in the time, so grab everything after "SCAN:"
5141                    text.split('|')
5142                        .find(|s| s.starts_with("SCAN:"))
5143                        .and_then(|s| s.get(5..))
5144                        .unwrap_or("unknown")
5145                        .to_string()
5146                };
5147                let def_ver = get("VER");
5148                let age_days: i64 = get("AGE").parse().unwrap_or(-1);
5149
5150                let rtp_label = if rtp == "True" {
5151                    "ENABLED"
5152                } else {
5153                    "DISABLED [!]"
5154                };
5155                out.push_str(&format!(
5156                    "Windows Defender real-time protection: {rtp_label}\n"
5157                ));
5158                out.push_str(&format!("Last quick scan: {last_scan}\n"));
5159                out.push_str(&format!("Signature version: {def_ver}\n"));
5160                if age_days >= 0 {
5161                    let freshness = if age_days == 0 {
5162                        "up to date".to_string()
5163                    } else if age_days <= 3 {
5164                        format!("{age_days} day(s) old — OK")
5165                    } else if age_days <= 7 {
5166                        format!("{age_days} day(s) old — consider updating")
5167                    } else {
5168                        format!("{age_days} day(s) old — [!] STALE, run Windows Update")
5169                    };
5170                    out.push_str(&format!("Signature age: {freshness}\n"));
5171                }
5172                if rtp != "True" {
5173                    out.push_str(
5174                        "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
5175                    );
5176                    out.push_str(
5177                        "    → Open Windows Security > Virus & threat protection to re-enable.\n",
5178                    );
5179                }
5180            }
5181        }
5182
5183        out.push('\n');
5184
5185        // Windows Firewall state
5186        let fw_script = r#"
5187try {
5188    Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
5189} catch { "ERROR:" + $_.Exception.Message }
5190"#;
5191        if let Ok(o) = Command::new("powershell")
5192            .args(["-NoProfile", "-Command", fw_script])
5193            .output()
5194        {
5195            let raw = String::from_utf8_lossy(&o.stdout);
5196            let text = raw.trim();
5197            if !text.starts_with("ERROR:") && !text.is_empty() {
5198                out.push_str("Windows Firewall:\n");
5199                for line in text.lines() {
5200                    if let Some((name, enabled)) = line.split_once(':') {
5201                        let state = if enabled.trim() == "True" {
5202                            "ON"
5203                        } else {
5204                            "OFF [!]"
5205                        };
5206                        out.push_str(&format!("  {name}: {state}\n"));
5207                    }
5208                }
5209                out.push('\n');
5210            }
5211        }
5212
5213        // Windows activation status
5214        let act_script = r#"
5215try {
5216    $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
5217    if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
5218} catch { "UNKNOWN" }
5219"#;
5220        if let Ok(o) = Command::new("powershell")
5221            .args(["-NoProfile", "-Command", act_script])
5222            .output()
5223        {
5224            let raw = String::from_utf8_lossy(&o.stdout);
5225            match raw.trim() {
5226                "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
5227                "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
5228                _ => out.push_str("Windows activation: Unable to determine\n"),
5229            }
5230        }
5231
5232        // UAC state
5233        let uac_script = r#"
5234$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
5235if ($val -eq 1) { "ON" } else { "OFF" }
5236"#;
5237        if let Ok(o) = Command::new("powershell")
5238            .args(["-NoProfile", "-Command", uac_script])
5239            .output()
5240        {
5241            let raw = String::from_utf8_lossy(&o.stdout);
5242            let state = raw.trim();
5243            let label = if state == "ON" {
5244                "Enabled"
5245            } else {
5246                "DISABLED [!] — recommended to re-enable via secpol.msc"
5247            };
5248            out.push_str(&format!("UAC (User Account Control): {label}\n"));
5249        }
5250    }
5251
5252    #[cfg(not(target_os = "windows"))]
5253    {
5254        if let Ok(o) = Command::new("ufw").arg("status").output() {
5255            let text = String::from_utf8_lossy(&o.stdout);
5256            out.push_str(&format!(
5257                "UFW: {}\n",
5258                text.lines().next().unwrap_or("unknown")
5259            ));
5260        }
5261        if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
5262            if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
5263                out.push_str(&format!("{line}\n"));
5264            }
5265        }
5266    }
5267
5268    Ok(out.trim_end().to_string())
5269}
5270
5271// ── pending_reboot ────────────────────────────────────────────────────────────
5272
5273fn inspect_pending_reboot() -> Result<String, String> {
5274    let mut out = String::from("Host inspection: pending_reboot\n\n");
5275
5276    #[cfg(target_os = "windows")]
5277    {
5278        let script = r#"
5279$reasons = @()
5280if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
5281    $reasons += "Windows Update requires a restart"
5282}
5283if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
5284    $reasons += "Windows component install/update requires a restart"
5285}
5286$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
5287if ($pfro -and $pfro.PendingFileRenameOperations) {
5288    $reasons += "Pending file rename operations (driver or system file replacement)"
5289}
5290if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
5291"#;
5292        let output = Command::new("powershell")
5293            .args(["-NoProfile", "-Command", script])
5294            .output()
5295            .map_err(|e| format!("pending_reboot: {e}"))?;
5296
5297        let raw = String::from_utf8_lossy(&output.stdout);
5298        let text = raw.trim();
5299
5300        if text == "NO_REBOOT_NEEDED" {
5301            out.push_str("No restart required — system is up to date and stable.\n");
5302        } else if text.is_empty() {
5303            out.push_str("Could not determine reboot status.\n");
5304        } else {
5305            out.push_str("[!] A system restart is pending:\n\n");
5306            for reason in text.split("|REASON|") {
5307                out.push_str(&format!("  • {}\n", reason.trim()));
5308            }
5309            out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
5310        }
5311    }
5312
5313    #[cfg(not(target_os = "windows"))]
5314    {
5315        if std::path::Path::new("/var/run/reboot-required").exists() {
5316            out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
5317            if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
5318                out.push_str("Packages requiring restart:\n");
5319                for p in pkgs.lines().take(10) {
5320                    out.push_str(&format!("  • {p}\n"));
5321                }
5322            }
5323        } else {
5324            out.push_str("No restart required.\n");
5325        }
5326    }
5327
5328    Ok(out.trim_end().to_string())
5329}
5330
5331// ── disk_health ───────────────────────────────────────────────────────────────
5332
5333fn inspect_disk_health() -> Result<String, String> {
5334    let mut out = String::from("Host inspection: disk_health\n\n");
5335
5336    #[cfg(target_os = "windows")]
5337    {
5338        let script = r#"
5339try {
5340    $disks = Get-PhysicalDisk -ErrorAction Stop
5341    foreach ($d in $disks) {
5342        $size_gb = [math]::Round($d.Size / 1GB, 0)
5343        $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
5344    }
5345} catch { "ERROR:" + $_.Exception.Message }
5346"#;
5347        let output = Command::new("powershell")
5348            .args(["-NoProfile", "-Command", script])
5349            .output()
5350            .map_err(|e| format!("disk_health: {e}"))?;
5351
5352        let raw = String::from_utf8_lossy(&output.stdout);
5353        let text = raw.trim();
5354
5355        if text.starts_with("ERROR:") {
5356            out.push_str(&format!("Unable to query disk health: {text}\n"));
5357            out.push_str("This may require running as administrator.\n");
5358        } else if text.is_empty() {
5359            out.push_str("No physical disks found.\n");
5360        } else {
5361            out.push_str("Physical Drive Health:\n\n");
5362            for line in text.lines() {
5363                let parts: Vec<&str> = line.splitn(5, '|').collect();
5364                if parts.len() >= 4 {
5365                    let name = parts[0];
5366                    let media = parts[1];
5367                    let size = parts[2];
5368                    let health = parts[3];
5369                    let op_status = parts.get(4).unwrap_or(&"");
5370                    let health_label = match health.trim() {
5371                        "Healthy" => "OK",
5372                        "Warning" => "[!] WARNING",
5373                        "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
5374                        other => other,
5375                    };
5376                    out.push_str(&format!("  {name}\n"));
5377                    out.push_str(&format!("    Type: {media} | Size: {size}\n"));
5378                    out.push_str(&format!("    Health: {health_label}\n"));
5379                    if !op_status.is_empty() {
5380                        out.push_str(&format!("    Status: {op_status}\n"));
5381                    }
5382                    out.push('\n');
5383                }
5384            }
5385        }
5386
5387        // SMART failure prediction (best-effort, may need admin)
5388        let smart_script = r#"
5389try {
5390    Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
5391        ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
5392} catch { "" }
5393"#;
5394        if let Ok(o) = Command::new("powershell")
5395            .args(["-NoProfile", "-Command", smart_script])
5396            .output()
5397        {
5398            let raw2 = String::from_utf8_lossy(&o.stdout);
5399            let text2 = raw2.trim();
5400            if !text2.is_empty() {
5401                let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
5402                if failures.is_empty() {
5403                    out.push_str("SMART failure prediction: No failures predicted\n");
5404                } else {
5405                    out.push_str("[!!] SMART failure predicted on one or more drives:\n");
5406                    for f in failures {
5407                        let name = f.split('|').next().unwrap_or(f);
5408                        out.push_str(&format!("  • {name}\n"));
5409                    }
5410                    out.push_str(
5411                        "\nBack up your data immediately and replace the failing drive.\n",
5412                    );
5413                }
5414            }
5415        }
5416    }
5417
5418    #[cfg(not(target_os = "windows"))]
5419    {
5420        if let Ok(o) = Command::new("lsblk")
5421            .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
5422            .output()
5423        {
5424            let text = String::from_utf8_lossy(&o.stdout);
5425            out.push_str("Block devices:\n");
5426            out.push_str(text.trim());
5427            out.push('\n');
5428        }
5429        if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
5430            let devices = String::from_utf8_lossy(&scan.stdout);
5431            for dev_line in devices.lines().take(4) {
5432                let dev = dev_line.split_whitespace().next().unwrap_or("");
5433                if dev.is_empty() {
5434                    continue;
5435                }
5436                if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
5437                    let health = String::from_utf8_lossy(&o.stdout);
5438                    if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
5439                    {
5440                        out.push_str(&format!("{dev}: {}\n", line.trim()));
5441                    }
5442                }
5443            }
5444        } else {
5445            out.push_str("(install smartmontools for SMART health data)\n");
5446        }
5447    }
5448
5449    Ok(out.trim_end().to_string())
5450}
5451
5452// ── battery ───────────────────────────────────────────────────────────────────
5453
5454fn inspect_battery() -> Result<String, String> {
5455    let mut out = String::from("Host inspection: battery\n\n");
5456
5457    #[cfg(target_os = "windows")]
5458    {
5459        let script = r#"
5460try {
5461    $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
5462    if (-not $bats) { "NO_BATTERY"; exit }
5463    
5464    # Modern Battery Health (Cycle count + Capacity health)
5465    $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
5466    $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue 
5467    $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
5468
5469    foreach ($b in $bats) {
5470        $state = switch ($b.BatteryStatus) {
5471            1 { "Discharging" }
5472            2 { "AC Power (Fully Charged)" }
5473            3 { "AC Power (Charging)" }
5474            default { "Status $($b.BatteryStatus)" }
5475        }
5476        
5477        $cycles = if ($status) { $status.CycleCount } else { "unknown" }
5478        $health = if ($static -and $full) {
5479             [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
5480        } else { "unknown" }
5481
5482        $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
5483    }
5484} catch { "ERROR:" + $_.Exception.Message }
5485"#;
5486        let output = Command::new("powershell")
5487            .args(["-NoProfile", "-Command", script])
5488            .output()
5489            .map_err(|e| format!("battery: {e}"))?;
5490
5491        let raw = String::from_utf8_lossy(&output.stdout);
5492        let text = raw.trim();
5493
5494        if text == "NO_BATTERY" {
5495            out.push_str("No battery detected — desktop or AC-only system.\n");
5496            return Ok(out.trim_end().to_string());
5497        }
5498        if text.starts_with("ERROR:") {
5499            out.push_str(&format!("Unable to query battery: {text}\n"));
5500            return Ok(out.trim_end().to_string());
5501        }
5502
5503        for line in text.lines() {
5504            let parts: Vec<&str> = line.split('|').collect();
5505            if parts.len() == 5 {
5506                let name = parts[0];
5507                let charge: i64 = parts[1].parse().unwrap_or(-1);
5508                let state = parts[2];
5509                let cycles = parts[3];
5510                let health = parts[4];
5511
5512                out.push_str(&format!("Battery: {name}\n"));
5513                if charge >= 0 {
5514                    let bar_filled = (charge as usize * 20) / 100;
5515                    out.push_str(&format!(
5516                        "  Charge: [{}{}] {}%\n",
5517                        "#".repeat(bar_filled),
5518                        ".".repeat(20 - bar_filled),
5519                        charge
5520                    ));
5521                }
5522                out.push_str(&format!("  Status: {state}\n"));
5523                out.push_str(&format!("  Cycles: {cycles}\n"));
5524                out.push_str(&format!(
5525                    "  Health: {health}% (Actual vs Design Capacity)\n\n"
5526                ));
5527            }
5528        }
5529    }
5530
5531    #[cfg(not(target_os = "windows"))]
5532    {
5533        let power_path = std::path::Path::new("/sys/class/power_supply");
5534        let mut found = false;
5535        if power_path.exists() {
5536            if let Ok(entries) = std::fs::read_dir(power_path) {
5537                for entry in entries.flatten() {
5538                    let p = entry.path();
5539                    if let Ok(t) = std::fs::read_to_string(p.join("type")) {
5540                        if t.trim() == "Battery" {
5541                            found = true;
5542                            let name = p
5543                                .file_name()
5544                                .unwrap_or_default()
5545                                .to_string_lossy()
5546                                .to_string();
5547                            out.push_str(&format!("Battery: {name}\n"));
5548                            let read = |f: &str| {
5549                                std::fs::read_to_string(p.join(f))
5550                                    .ok()
5551                                    .map(|s| s.trim().to_string())
5552                            };
5553                            if let Some(cap) = read("capacity") {
5554                                out.push_str(&format!("  Charge: {cap}%\n"));
5555                            }
5556                            if let Some(status) = read("status") {
5557                                out.push_str(&format!("  Status: {status}\n"));
5558                            }
5559                            if let (Some(full), Some(design)) =
5560                                (read("energy_full"), read("energy_full_design"))
5561                            {
5562                                if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
5563                                {
5564                                    if d > 0.0 {
5565                                        out.push_str(&format!(
5566                                            "  Wear level: {:.1}% of design capacity\n",
5567                                            (f / d) * 100.0
5568                                        ));
5569                                    }
5570                                }
5571                            }
5572                        }
5573                    }
5574                }
5575            }
5576        }
5577        if !found {
5578            out.push_str("No battery found.\n");
5579        }
5580    }
5581
5582    Ok(out.trim_end().to_string())
5583}
5584
5585// ── recent_crashes ────────────────────────────────────────────────────────────
5586
5587fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
5588    let mut out = String::from("Host inspection: recent_crashes\n\n");
5589    let n = max_entries.clamp(1, 30);
5590
5591    #[cfg(target_os = "windows")]
5592    {
5593        // BSODs / unexpected shutdowns (EventID 41 = kernel power, 1001 = BugCheck)
5594        let bsod_script = format!(
5595            r#"
5596try {{
5597    $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
5598    if ($events) {{
5599        $events | ForEach-Object {{
5600            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5601        }}
5602    }} else {{ "NO_BSOD" }}
5603}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5604        );
5605
5606        if let Ok(o) = Command::new("powershell")
5607            .args(["-NoProfile", "-Command", &bsod_script])
5608            .output()
5609        {
5610            let raw = String::from_utf8_lossy(&o.stdout);
5611            let text = raw.trim();
5612            if text == "NO_BSOD" {
5613                out.push_str("System crashes (BSOD/kernel): None in recent history\n");
5614            } else if text.starts_with("ERROR:") {
5615                out.push_str("System crashes: unable to query\n");
5616            } else {
5617                out.push_str("System crashes / unexpected shutdowns:\n");
5618                for line in text.lines() {
5619                    let parts: Vec<&str> = line.splitn(3, '|').collect();
5620                    if parts.len() >= 3 {
5621                        let time = parts[0];
5622                        let id = parts[1];
5623                        let msg = parts[2];
5624                        let label = if id == "41" {
5625                            "Unexpected shutdown"
5626                        } else {
5627                            "BSOD (BugCheck)"
5628                        };
5629                        out.push_str(&format!("  [{time}] {label}: {msg}\n"));
5630                    }
5631                }
5632                out.push('\n');
5633            }
5634        }
5635
5636        // Application crashes (EventID 1000 = app crash, 1002 = app hang)
5637        let app_script = format!(
5638            r#"
5639try {{
5640    $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
5641    if ($crashes) {{
5642        $crashes | ForEach-Object {{
5643            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5644        }}
5645    }} else {{ "NO_CRASHES" }}
5646}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
5647        );
5648
5649        if let Ok(o) = Command::new("powershell")
5650            .args(["-NoProfile", "-Command", &app_script])
5651            .output()
5652        {
5653            let raw = String::from_utf8_lossy(&o.stdout);
5654            let text = raw.trim();
5655            if text == "NO_CRASHES" {
5656                out.push_str("Application crashes: None in recent history\n");
5657            } else if text.starts_with("ERROR_APP:") {
5658                out.push_str("Application crashes: unable to query\n");
5659            } else {
5660                out.push_str("Application crashes:\n");
5661                for line in text.lines().take(n) {
5662                    let parts: Vec<&str> = line.splitn(2, '|').collect();
5663                    if parts.len() >= 2 {
5664                        out.push_str(&format!("  [{}] {}\n", parts[0], parts[1]));
5665                    }
5666                }
5667            }
5668        }
5669    }
5670
5671    #[cfg(not(target_os = "windows"))]
5672    {
5673        let n_str = n.to_string();
5674        if let Ok(o) = Command::new("journalctl")
5675            .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
5676            .output()
5677        {
5678            let text = String::from_utf8_lossy(&o.stdout);
5679            let trimmed = text.trim();
5680            if trimmed.is_empty() || trimmed.contains("No entries") {
5681                out.push_str("No kernel panics or critical crashes found.\n");
5682            } else {
5683                out.push_str("Kernel critical events:\n");
5684                out.push_str(trimmed);
5685                out.push('\n');
5686            }
5687        }
5688        if let Ok(o) = Command::new("coredumpctl")
5689            .args(["list", "--no-pager"])
5690            .output()
5691        {
5692            let text = String::from_utf8_lossy(&o.stdout);
5693            let count = text
5694                .lines()
5695                .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
5696                .count();
5697            if count > 0 {
5698                out.push_str(&format!(
5699                    "\nCore dumps on file: {count}\n  → Run: coredumpctl list\n"
5700                ));
5701            }
5702        }
5703    }
5704
5705    Ok(out.trim_end().to_string())
5706}
5707
5708// ── scheduled_tasks ───────────────────────────────────────────────────────────
5709
5710fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
5711    let mut out = String::from("Host inspection: scheduled_tasks\n\n");
5712    let n = max_entries.clamp(1, 30);
5713
5714    #[cfg(target_os = "windows")]
5715    {
5716        let script = format!(
5717            r#"
5718try {{
5719    $tasks = Get-ScheduledTask -ErrorAction Stop |
5720        Where-Object {{ $_.State -ne 'Disabled' }} |
5721        ForEach-Object {{
5722            $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
5723            $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
5724                $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
5725            }} else {{ "never" }}
5726            $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
5727            $exec = ($_.Actions | Select-Object -First 1).Execute
5728            if (-not $exec) {{ $exec = "(no exec)" }}
5729            $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
5730        }}
5731    $tasks | Select-Object -First {n}
5732}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5733        );
5734
5735        let output = Command::new("powershell")
5736            .args(["-NoProfile", "-Command", &script])
5737            .output()
5738            .map_err(|e| format!("scheduled_tasks: {e}"))?;
5739
5740        let raw = String::from_utf8_lossy(&output.stdout);
5741        let text = raw.trim();
5742
5743        if text.starts_with("ERROR:") {
5744            out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
5745        } else if text.is_empty() {
5746            out.push_str("No active scheduled tasks found.\n");
5747        } else {
5748            out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
5749            for line in text.lines() {
5750                let parts: Vec<&str> = line.splitn(6, '|').collect();
5751                if parts.len() >= 5 {
5752                    let name = parts[0];
5753                    let path = parts[1];
5754                    let state = parts[2];
5755                    let last = parts[3];
5756                    let res = parts[4];
5757                    let exec = parts.get(5).unwrap_or(&"").trim();
5758                    let display_path = path.trim_matches('\\');
5759                    let display_path = if display_path.is_empty() {
5760                        "Root"
5761                    } else {
5762                        display_path
5763                    };
5764                    out.push_str(&format!("  {name} [{display_path}]\n"));
5765                    out.push_str(&format!(
5766                        "    State: {state} | Last run: {last} | Result: {res}\n"
5767                    ));
5768                    if !exec.is_empty() && exec != "(no exec)" {
5769                        let short = if exec.len() > 80 { &exec[..80] } else { exec };
5770                        out.push_str(&format!("    Runs: {short}\n"));
5771                    }
5772                }
5773            }
5774        }
5775    }
5776
5777    #[cfg(not(target_os = "windows"))]
5778    {
5779        if let Ok(o) = Command::new("systemctl")
5780            .args(["list-timers", "--no-pager", "--all"])
5781            .output()
5782        {
5783            let text = String::from_utf8_lossy(&o.stdout);
5784            out.push_str("Systemd timers:\n");
5785            for l in text
5786                .lines()
5787                .filter(|l| {
5788                    !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
5789                })
5790                .take(n)
5791            {
5792                out.push_str(&format!("  {l}\n"));
5793            }
5794            out.push('\n');
5795        }
5796        if let Ok(o) = Command::new("crontab").arg("-l").output() {
5797            let text = String::from_utf8_lossy(&o.stdout);
5798            let jobs: Vec<&str> = text
5799                .lines()
5800                .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
5801                .collect();
5802            if !jobs.is_empty() {
5803                out.push_str("User crontab:\n");
5804                for j in jobs.iter().take(n) {
5805                    out.push_str(&format!("  {j}\n"));
5806                }
5807            }
5808        }
5809    }
5810
5811    Ok(out.trim_end().to_string())
5812}
5813
5814// ── dev_conflicts ─────────────────────────────────────────────────────────────
5815
5816fn inspect_dev_conflicts() -> Result<String, String> {
5817    let mut out = String::from("Host inspection: dev_conflicts\n\n");
5818    let mut conflicts: Vec<String> = Vec::new();
5819    let mut notes: Vec<String> = Vec::new();
5820
5821    // ── Node.js / version managers ────────────────────────────────────────────
5822    {
5823        let node_ver = Command::new("node")
5824            .arg("--version")
5825            .output()
5826            .ok()
5827            .and_then(|o| String::from_utf8(o.stdout).ok())
5828            .map(|s| s.trim().to_string());
5829        let nvm_active = Command::new("nvm")
5830            .arg("current")
5831            .output()
5832            .ok()
5833            .and_then(|o| String::from_utf8(o.stdout).ok())
5834            .map(|s| s.trim().to_string())
5835            .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
5836        let fnm_active = Command::new("fnm")
5837            .arg("current")
5838            .output()
5839            .ok()
5840            .and_then(|o| String::from_utf8(o.stdout).ok())
5841            .map(|s| s.trim().to_string())
5842            .filter(|s| !s.is_empty() && !s.contains("none"));
5843        let volta_active = Command::new("volta")
5844            .args(["which", "node"])
5845            .output()
5846            .ok()
5847            .and_then(|o| String::from_utf8(o.stdout).ok())
5848            .map(|s| s.trim().to_string())
5849            .filter(|s| !s.is_empty());
5850
5851        out.push_str("Node.js:\n");
5852        if let Some(ref v) = node_ver {
5853            out.push_str(&format!("  Active: {v}\n"));
5854        } else {
5855            out.push_str("  Not installed\n");
5856        }
5857        let managers: Vec<&str> = [
5858            nvm_active.as_deref(),
5859            fnm_active.as_deref(),
5860            volta_active.as_deref(),
5861        ]
5862        .iter()
5863        .filter_map(|x| *x)
5864        .collect();
5865        if managers.len() > 1 {
5866            conflicts.push(format!(
5867                "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
5868            ));
5869        } else if !managers.is_empty() {
5870            out.push_str(&format!("  Version manager: {}\n", managers[0]));
5871        }
5872        out.push('\n');
5873    }
5874
5875    // ── Python ────────────────────────────────────────────────────────────────
5876    {
5877        let py3 = Command::new("python3")
5878            .arg("--version")
5879            .output()
5880            .ok()
5881            .and_then(|o| {
5882                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5883                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5884                let v = if stdout.is_empty() { stderr } else { stdout };
5885                if v.is_empty() {
5886                    None
5887                } else {
5888                    Some(v)
5889                }
5890            });
5891        let py = Command::new("python")
5892            .arg("--version")
5893            .output()
5894            .ok()
5895            .and_then(|o| {
5896                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5897                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5898                let v = if stdout.is_empty() { stderr } else { stdout };
5899                if v.is_empty() {
5900                    None
5901                } else {
5902                    Some(v)
5903                }
5904            });
5905        let pyenv = Command::new("pyenv")
5906            .arg("version")
5907            .output()
5908            .ok()
5909            .and_then(|o| String::from_utf8(o.stdout).ok())
5910            .map(|s| s.trim().to_string())
5911            .filter(|s| !s.is_empty());
5912        let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
5913
5914        out.push_str("Python:\n");
5915        match (&py3, &py) {
5916            (Some(v3), Some(v)) if v3 != v => {
5917                out.push_str(&format!("  python3: {v3}\n  python:  {v}\n"));
5918                if v.contains("2.") {
5919                    conflicts.push(
5920                        "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
5921                    );
5922                } else {
5923                    notes.push(
5924                        "python and python3 resolve to different minor versions.".to_string(),
5925                    );
5926                }
5927            }
5928            (Some(v3), None) => out.push_str(&format!("  python3: {v3}\n")),
5929            (None, Some(v)) => out.push_str(&format!("  python: {v}\n")),
5930            (Some(v3), Some(_)) => out.push_str(&format!("  {v3}\n")),
5931            (None, None) => out.push_str("  Not installed\n"),
5932        }
5933        if let Some(ref pe) = pyenv {
5934            out.push_str(&format!("  pyenv: {pe}\n"));
5935        }
5936        if let Some(env) = conda_env {
5937            if env == "base" {
5938                notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
5939            } else {
5940                out.push_str(&format!("  conda env: {env}\n"));
5941            }
5942        }
5943        out.push('\n');
5944    }
5945
5946    // ── Rust / Cargo ──────────────────────────────────────────────────────────
5947    {
5948        let toolchain = Command::new("rustup")
5949            .args(["show", "active-toolchain"])
5950            .output()
5951            .ok()
5952            .and_then(|o| String::from_utf8(o.stdout).ok())
5953            .map(|s| s.trim().to_string())
5954            .filter(|s| !s.is_empty());
5955        let cargo_ver = Command::new("cargo")
5956            .arg("--version")
5957            .output()
5958            .ok()
5959            .and_then(|o| String::from_utf8(o.stdout).ok())
5960            .map(|s| s.trim().to_string());
5961        let rustc_ver = Command::new("rustc")
5962            .arg("--version")
5963            .output()
5964            .ok()
5965            .and_then(|o| String::from_utf8(o.stdout).ok())
5966            .map(|s| s.trim().to_string());
5967
5968        out.push_str("Rust:\n");
5969        if let Some(ref t) = toolchain {
5970            out.push_str(&format!("  Active toolchain: {t}\n"));
5971        }
5972        if let Some(ref c) = cargo_ver {
5973            out.push_str(&format!("  {c}\n"));
5974        }
5975        if let Some(ref r) = rustc_ver {
5976            out.push_str(&format!("  {r}\n"));
5977        }
5978        if cargo_ver.is_none() && rustc_ver.is_none() {
5979            out.push_str("  Not installed\n");
5980        }
5981
5982        // Detect system rust that might shadow rustup
5983        #[cfg(not(target_os = "windows"))]
5984        if let Ok(o) = Command::new("which").arg("rustc").output() {
5985            let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
5986            if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
5987                conflicts.push(format!(
5988                    "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
5989                ));
5990            }
5991        }
5992        out.push('\n');
5993    }
5994
5995    // ── Git ───────────────────────────────────────────────────────────────────
5996    {
5997        let git_ver = Command::new("git")
5998            .arg("--version")
5999            .output()
6000            .ok()
6001            .and_then(|o| String::from_utf8(o.stdout).ok())
6002            .map(|s| s.trim().to_string());
6003        out.push_str("Git:\n");
6004        if let Some(ref v) = git_ver {
6005            out.push_str(&format!("  {v}\n"));
6006            let email = Command::new("git")
6007                .args(["config", "--global", "user.email"])
6008                .output()
6009                .ok()
6010                .and_then(|o| String::from_utf8(o.stdout).ok())
6011                .map(|s| s.trim().to_string());
6012            if let Some(ref e) = email {
6013                if e.is_empty() {
6014                    notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
6015                } else {
6016                    out.push_str(&format!("  user.email: {e}\n"));
6017                }
6018            }
6019            let gpg_sign = Command::new("git")
6020                .args(["config", "--global", "commit.gpgsign"])
6021                .output()
6022                .ok()
6023                .and_then(|o| String::from_utf8(o.stdout).ok())
6024                .map(|s| s.trim().to_string());
6025            if gpg_sign.as_deref() == Some("true") {
6026                let key = Command::new("git")
6027                    .args(["config", "--global", "user.signingkey"])
6028                    .output()
6029                    .ok()
6030                    .and_then(|o| String::from_utf8(o.stdout).ok())
6031                    .map(|s| s.trim().to_string());
6032                if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
6033                    conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
6034                }
6035            }
6036        } else {
6037            out.push_str("  Not installed\n");
6038        }
6039        out.push('\n');
6040    }
6041
6042    // ── PATH duplicates ───────────────────────────────────────────────────────
6043    {
6044        let path_env = std::env::var("PATH").unwrap_or_default();
6045        let sep = if cfg!(windows) { ';' } else { ':' };
6046        let mut seen = HashSet::new();
6047        let mut dupes: Vec<String> = Vec::new();
6048        for p in path_env.split(sep) {
6049            let norm = p.trim().to_lowercase();
6050            if !norm.is_empty() && !seen.insert(norm) {
6051                dupes.push(p.to_string());
6052            }
6053        }
6054        if !dupes.is_empty() {
6055            let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
6056            notes.push(format!(
6057                "Duplicate PATH entries: {} {}",
6058                shown.join(", "),
6059                if dupes.len() > 3 {
6060                    format!("+{} more", dupes.len() - 3)
6061                } else {
6062                    String::new()
6063                }
6064            ));
6065        }
6066    }
6067
6068    // ── Summary ───────────────────────────────────────────────────────────────
6069    if conflicts.is_empty() && notes.is_empty() {
6070        out.push_str("No conflicts detected — dev environment looks clean.\n");
6071    } else {
6072        if !conflicts.is_empty() {
6073            out.push_str("CONFLICTS:\n");
6074            for c in &conflicts {
6075                out.push_str(&format!("  [!] {c}\n"));
6076            }
6077            out.push('\n');
6078        }
6079        if !notes.is_empty() {
6080            out.push_str("NOTES:\n");
6081            for n in &notes {
6082                out.push_str(&format!("  [-] {n}\n"));
6083            }
6084        }
6085    }
6086
6087    Ok(out.trim_end().to_string())
6088}
6089
6090// ── connectivity ──────────────────────────────────────────────────────────────
6091
6092fn inspect_connectivity() -> Result<String, String> {
6093    let mut out = String::from("Host inspection: connectivity\n\n");
6094
6095    #[cfg(target_os = "windows")]
6096    {
6097        let inet_script = r#"
6098try {
6099    $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
6100    if ($r) { "REACHABLE" } else { "UNREACHABLE" }
6101} catch { "ERROR:" + $_.Exception.Message }
6102"#;
6103        if let Ok(o) = Command::new("powershell")
6104            .args(["-NoProfile", "-Command", inet_script])
6105            .output()
6106        {
6107            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6108            match text.as_str() {
6109                "REACHABLE" => out.push_str("Internet: reachable\n"),
6110                "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
6111                _ => out.push_str(&format!(
6112                    "Internet: {}\n",
6113                    text.trim_start_matches("ERROR:").trim()
6114                )),
6115            }
6116        }
6117
6118        let dns_script = r#"
6119try {
6120    Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
6121    "DNS:ok"
6122} catch { "DNS:fail:" + $_.Exception.Message }
6123"#;
6124        if let Ok(o) = Command::new("powershell")
6125            .args(["-NoProfile", "-Command", dns_script])
6126            .output()
6127        {
6128            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6129            if text == "DNS:ok" {
6130                out.push_str("DNS: resolving correctly\n");
6131            } else {
6132                let detail = text.trim_start_matches("DNS:fail:").trim();
6133                out.push_str(&format!("DNS: failed — {}\n", detail));
6134            }
6135        }
6136
6137        let gw_script = r#"
6138(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
6139"#;
6140        if let Ok(o) = Command::new("powershell")
6141            .args(["-NoProfile", "-Command", gw_script])
6142            .output()
6143        {
6144            let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
6145            if !gw.is_empty() && gw != "0.0.0.0" {
6146                out.push_str(&format!("Default gateway: {}\n", gw));
6147            }
6148        }
6149    }
6150
6151    #[cfg(not(target_os = "windows"))]
6152    {
6153        let reachable = Command::new("ping")
6154            .args(["-c", "1", "-W", "2", "8.8.8.8"])
6155            .output()
6156            .map(|o| o.status.success())
6157            .unwrap_or(false);
6158        out.push_str(if reachable {
6159            "Internet: reachable\n"
6160        } else {
6161            "Internet: unreachable\n"
6162        });
6163        let dns_ok = Command::new("getent")
6164            .args(["hosts", "dns.google"])
6165            .output()
6166            .map(|o| o.status.success())
6167            .unwrap_or(false);
6168        out.push_str(if dns_ok {
6169            "DNS: resolving correctly\n"
6170        } else {
6171            "DNS: failed\n"
6172        });
6173        if let Ok(o) = Command::new("ip")
6174            .args(["route", "show", "default"])
6175            .output()
6176        {
6177            let text = String::from_utf8_lossy(&o.stdout);
6178            if let Some(line) = text.lines().next() {
6179                out.push_str(&format!("Default gateway: {}\n", line.trim()));
6180            }
6181        }
6182    }
6183
6184    Ok(out.trim_end().to_string())
6185}
6186
6187// ── wifi ──────────────────────────────────────────────────────────────────────
6188
6189fn inspect_wifi() -> Result<String, String> {
6190    let mut out = String::from("Host inspection: wifi\n\n");
6191
6192    #[cfg(target_os = "windows")]
6193    {
6194        let output = Command::new("netsh")
6195            .args(["wlan", "show", "interfaces"])
6196            .output()
6197            .map_err(|e| format!("wifi: {e}"))?;
6198        let text = String::from_utf8_lossy(&output.stdout).to_string();
6199
6200        if text.contains("There is no wireless interface") || text.trim().is_empty() {
6201            out.push_str("No wireless interface detected on this machine.\n");
6202            return Ok(out.trim_end().to_string());
6203        }
6204
6205        let fields = [
6206            ("SSID", "SSID"),
6207            ("State", "State"),
6208            ("Signal", "Signal"),
6209            ("Radio type", "Radio type"),
6210            ("Channel", "Channel"),
6211            ("Receive rate (Mbps)", "Download speed (Mbps)"),
6212            ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
6213            ("Authentication", "Authentication"),
6214            ("Network type", "Network type"),
6215        ];
6216
6217        let mut any = false;
6218        for line in text.lines() {
6219            let trimmed = line.trim();
6220            for (key, label) in &fields {
6221                if trimmed.starts_with(key) && trimmed.contains(':') {
6222                    let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
6223                    if !val.is_empty() {
6224                        out.push_str(&format!("  {label}: {val}\n"));
6225                        any = true;
6226                    }
6227                }
6228            }
6229        }
6230        if !any {
6231            out.push_str("  (Wi-Fi adapter disconnected or no active connection)\n");
6232        }
6233    }
6234
6235    #[cfg(not(target_os = "windows"))]
6236    {
6237        if let Ok(o) = Command::new("nmcli")
6238            .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
6239            .output()
6240        {
6241            let text = String::from_utf8_lossy(&o.stdout).to_string();
6242            let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
6243            if lines.is_empty() {
6244                out.push_str("No Wi-Fi devices found.\n");
6245            } else {
6246                for l in lines {
6247                    out.push_str(&format!("  {l}\n"));
6248                }
6249            }
6250        } else if let Ok(o) = Command::new("iwconfig").output() {
6251            let text = String::from_utf8_lossy(&o.stdout).to_string();
6252            if !text.trim().is_empty() {
6253                out.push_str(text.trim());
6254                out.push('\n');
6255            }
6256        } else {
6257            out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
6258        }
6259    }
6260
6261    Ok(out.trim_end().to_string())
6262}
6263
6264// ── connections ───────────────────────────────────────────────────────────────
6265
6266fn inspect_connections(max_entries: usize) -> Result<String, String> {
6267    let mut out = String::from("Host inspection: connections\n\n");
6268    let n = max_entries.clamp(1, 25);
6269
6270    #[cfg(target_os = "windows")]
6271    {
6272        let script = format!(
6273            r#"
6274try {{
6275    $procs = @{{}}
6276    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
6277    $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
6278        Sort-Object OwningProcess
6279    "TOTAL:" + $all.Count
6280    $all | Select-Object -First {n} | ForEach-Object {{
6281        $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
6282        $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
6283    }}
6284}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6285        );
6286
6287        let output = Command::new("powershell")
6288            .args(["-NoProfile", "-Command", &script])
6289            .output()
6290            .map_err(|e| format!("connections: {e}"))?;
6291
6292        let raw = String::from_utf8_lossy(&output.stdout);
6293        let text = raw.trim();
6294
6295        if text.starts_with("ERROR:") {
6296            out.push_str(&format!("Unable to query connections: {text}\n"));
6297        } else {
6298            let mut total = 0usize;
6299            let mut rows = Vec::new();
6300            for line in text.lines() {
6301                if let Some(rest) = line.strip_prefix("TOTAL:") {
6302                    total = rest.trim().parse().unwrap_or(0);
6303                } else {
6304                    rows.push(line);
6305                }
6306            }
6307            out.push_str(&format!("Established TCP connections: {total}\n\n"));
6308            for row in &rows {
6309                let parts: Vec<&str> = row.splitn(4, '|').collect();
6310                if parts.len() == 4 {
6311                    out.push_str(&format!(
6312                        "  {:<15} (pid {:<5}) | {} → {}\n",
6313                        parts[0], parts[1], parts[2], parts[3]
6314                    ));
6315                }
6316            }
6317            if total > n {
6318                out.push_str(&format!(
6319                    "\n  ... {} more connections not shown\n",
6320                    total.saturating_sub(n)
6321                ));
6322            }
6323        }
6324    }
6325
6326    #[cfg(not(target_os = "windows"))]
6327    {
6328        if let Ok(o) = Command::new("ss")
6329            .args(["-tnp", "state", "established"])
6330            .output()
6331        {
6332            let text = String::from_utf8_lossy(&o.stdout);
6333            let lines: Vec<&str> = text
6334                .lines()
6335                .skip(1)
6336                .filter(|l| !l.trim().is_empty())
6337                .collect();
6338            out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
6339            for line in lines.iter().take(n) {
6340                out.push_str(&format!("  {}\n", line.trim()));
6341            }
6342            if lines.len() > n {
6343                out.push_str(&format!("\n  ... {} more not shown\n", lines.len() - n));
6344            }
6345        } else {
6346            out.push_str("ss not available — install iproute2\n");
6347        }
6348    }
6349
6350    Ok(out.trim_end().to_string())
6351}
6352
6353// ── vpn ───────────────────────────────────────────────────────────────────────
6354
6355fn inspect_vpn() -> Result<String, String> {
6356    let mut out = String::from("Host inspection: vpn\n\n");
6357
6358    #[cfg(target_os = "windows")]
6359    {
6360        let script = r#"
6361try {
6362    $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
6363        $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
6364        $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
6365    }
6366    if ($vpn) {
6367        foreach ($a in $vpn) {
6368            $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
6369        }
6370    } else { "NONE" }
6371} catch { "ERROR:" + $_.Exception.Message }
6372"#;
6373        let output = Command::new("powershell")
6374            .args(["-NoProfile", "-Command", script])
6375            .output()
6376            .map_err(|e| format!("vpn: {e}"))?;
6377
6378        let raw = String::from_utf8_lossy(&output.stdout);
6379        let text = raw.trim();
6380
6381        if text == "NONE" {
6382            out.push_str("No VPN adapters detected — no active VPN connection found.\n");
6383        } else if text.starts_with("ERROR:") {
6384            out.push_str(&format!("Unable to query adapters: {text}\n"));
6385        } else {
6386            out.push_str("VPN adapters:\n\n");
6387            for line in text.lines() {
6388                let parts: Vec<&str> = line.splitn(4, '|').collect();
6389                if parts.len() >= 3 {
6390                    let name = parts[0];
6391                    let desc = parts[1];
6392                    let status = parts[2];
6393                    let media = parts.get(3).unwrap_or(&"unknown");
6394                    let label = if status.trim() == "Up" {
6395                        "CONNECTED"
6396                    } else {
6397                        "disconnected"
6398                    };
6399                    out.push_str(&format!(
6400                        "  {name} [{label}]\n    {desc}\n    Status: {status} | Media: {media}\n\n"
6401                    ));
6402                }
6403            }
6404        }
6405
6406        // Windows built-in VPN connections
6407        let ras_script = r#"
6408try {
6409    $c = Get-VpnConnection -ErrorAction Stop
6410    if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
6411    else { "NO_RAS" }
6412} catch { "NO_RAS" }
6413"#;
6414        if let Ok(o) = Command::new("powershell")
6415            .args(["-NoProfile", "-Command", ras_script])
6416            .output()
6417        {
6418            let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
6419            if t != "NO_RAS" && !t.is_empty() {
6420                out.push_str("Windows VPN connections:\n");
6421                for line in t.lines() {
6422                    let parts: Vec<&str> = line.splitn(3, '|').collect();
6423                    if parts.len() >= 2 {
6424                        let name = parts[0];
6425                        let status = parts[1];
6426                        let server = parts.get(2).unwrap_or(&"");
6427                        out.push_str(&format!("  {name} → {server} [{status}]\n"));
6428                    }
6429                }
6430            }
6431        }
6432    }
6433
6434    #[cfg(not(target_os = "windows"))]
6435    {
6436        if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
6437            let text = String::from_utf8_lossy(&o.stdout);
6438            let vpn_ifaces: Vec<&str> = text
6439                .lines()
6440                .filter(|l| {
6441                    l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
6442                })
6443                .collect();
6444            if vpn_ifaces.is_empty() {
6445                out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
6446            } else {
6447                out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
6448                for l in vpn_ifaces {
6449                    out.push_str(&format!("  {}\n", l.trim()));
6450                }
6451            }
6452        }
6453    }
6454
6455    Ok(out.trim_end().to_string())
6456}
6457
6458// ── proxy ─────────────────────────────────────────────────────────────────────
6459
6460fn inspect_proxy() -> Result<String, String> {
6461    let mut out = String::from("Host inspection: proxy\n\n");
6462
6463    #[cfg(target_os = "windows")]
6464    {
6465        let script = r#"
6466$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
6467if ($ie) {
6468    "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
6469} else { "NONE" }
6470"#;
6471        if let Ok(o) = Command::new("powershell")
6472            .args(["-NoProfile", "-Command", script])
6473            .output()
6474        {
6475            let raw = String::from_utf8_lossy(&o.stdout);
6476            let text = raw.trim();
6477            if text != "NONE" && !text.is_empty() {
6478                let get = |key: &str| -> &str {
6479                    text.split('|')
6480                        .find(|s| s.starts_with(key))
6481                        .and_then(|s| s.splitn(2, ':').nth(1))
6482                        .unwrap_or("")
6483                };
6484                let enabled = get("ENABLE");
6485                let server = get("SERVER");
6486                let overrides = get("OVERRIDE");
6487                out.push_str("WinINET / IE proxy:\n");
6488                out.push_str(&format!(
6489                    "  Enabled: {}\n",
6490                    if enabled == "1" { "yes" } else { "no" }
6491                ));
6492                if !server.is_empty() && server != "None" {
6493                    out.push_str(&format!("  Proxy server: {server}\n"));
6494                }
6495                if !overrides.is_empty() && overrides != "None" {
6496                    out.push_str(&format!("  Bypass list: {overrides}\n"));
6497                }
6498                out.push('\n');
6499            }
6500        }
6501
6502        if let Ok(o) = Command::new("netsh")
6503            .args(["winhttp", "show", "proxy"])
6504            .output()
6505        {
6506            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6507            out.push_str("WinHTTP proxy:\n");
6508            for line in text.lines() {
6509                let l = line.trim();
6510                if !l.is_empty() {
6511                    out.push_str(&format!("  {l}\n"));
6512                }
6513            }
6514            out.push('\n');
6515        }
6516
6517        let mut env_found = false;
6518        for var in &[
6519            "http_proxy",
6520            "https_proxy",
6521            "HTTP_PROXY",
6522            "HTTPS_PROXY",
6523            "no_proxy",
6524            "NO_PROXY",
6525        ] {
6526            if let Ok(val) = std::env::var(var) {
6527                if !env_found {
6528                    out.push_str("Environment proxy variables:\n");
6529                    env_found = true;
6530                }
6531                out.push_str(&format!("  {var}: {val}\n"));
6532            }
6533        }
6534        if !env_found {
6535            out.push_str("No proxy environment variables set.\n");
6536        }
6537    }
6538
6539    #[cfg(not(target_os = "windows"))]
6540    {
6541        let mut found = false;
6542        for var in &[
6543            "http_proxy",
6544            "https_proxy",
6545            "HTTP_PROXY",
6546            "HTTPS_PROXY",
6547            "no_proxy",
6548            "NO_PROXY",
6549            "ALL_PROXY",
6550            "all_proxy",
6551        ] {
6552            if let Ok(val) = std::env::var(var) {
6553                if !found {
6554                    out.push_str("Proxy environment variables:\n");
6555                    found = true;
6556                }
6557                out.push_str(&format!("  {var}: {val}\n"));
6558            }
6559        }
6560        if !found {
6561            out.push_str("No proxy environment variables set.\n");
6562        }
6563        if let Ok(content) = std::fs::read_to_string("/etc/environment") {
6564            let proxy_lines: Vec<&str> = content
6565                .lines()
6566                .filter(|l| l.to_lowercase().contains("proxy"))
6567                .collect();
6568            if !proxy_lines.is_empty() {
6569                out.push_str("\nSystem proxy (/etc/environment):\n");
6570                for l in proxy_lines {
6571                    out.push_str(&format!("  {l}\n"));
6572                }
6573            }
6574        }
6575    }
6576
6577    Ok(out.trim_end().to_string())
6578}
6579
6580// ── firewall_rules ────────────────────────────────────────────────────────────
6581
6582fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
6583    let mut out = String::from("Host inspection: firewall_rules\n\n");
6584    let n = max_entries.clamp(1, 20);
6585
6586    #[cfg(target_os = "windows")]
6587    {
6588        let script = format!(
6589            r#"
6590try {{
6591    $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
6592        Where-Object {{
6593            $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
6594            $_.Owner -eq $null
6595        }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
6596    "TOTAL:" + $rules.Count
6597    $rules | ForEach-Object {{
6598        $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
6599        $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
6600        $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
6601    }}
6602}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6603        );
6604
6605        let output = Command::new("powershell")
6606            .args(["-NoProfile", "-Command", &script])
6607            .output()
6608            .map_err(|e| format!("firewall_rules: {e}"))?;
6609
6610        let raw = String::from_utf8_lossy(&output.stdout);
6611        let text = raw.trim();
6612
6613        if text.starts_with("ERROR:") {
6614            out.push_str(&format!(
6615                "Unable to query firewall rules: {}\n",
6616                text.trim_start_matches("ERROR:").trim()
6617            ));
6618            out.push_str("This query may require running as administrator.\n");
6619        } else if text.is_empty() {
6620            out.push_str("No non-default enabled firewall rules found.\n");
6621        } else {
6622            let mut total = 0usize;
6623            for line in text.lines() {
6624                if let Some(rest) = line.strip_prefix("TOTAL:") {
6625                    total = rest.trim().parse().unwrap_or(0);
6626                    out.push_str(&format!(
6627                        "Non-default enabled rules (showing up to {n}):\n\n"
6628                    ));
6629                } else {
6630                    let parts: Vec<&str> = line.splitn(4, '|').collect();
6631                    if parts.len() >= 3 {
6632                        let name = parts[0];
6633                        let dir = parts[1];
6634                        let action = parts[2];
6635                        let profile = parts.get(3).unwrap_or(&"Any");
6636                        let icon = if action == "Block" { "[!]" } else { "   " };
6637                        out.push_str(&format!(
6638                            "  {icon} [{dir}] {action}: {name} (profile: {profile})\n"
6639                        ));
6640                    }
6641                }
6642            }
6643            if total == 0 {
6644                out.push_str("No non-default enabled rules found.\n");
6645            }
6646        }
6647    }
6648
6649    #[cfg(not(target_os = "windows"))]
6650    {
6651        if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
6652            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6653            if !text.is_empty() {
6654                out.push_str(&text);
6655                out.push('\n');
6656            }
6657        } else if let Ok(o) = Command::new("iptables")
6658            .args(["-L", "-n", "--line-numbers"])
6659            .output()
6660        {
6661            let text = String::from_utf8_lossy(&o.stdout);
6662            for l in text.lines().take(n * 2) {
6663                out.push_str(&format!("  {l}\n"));
6664            }
6665        } else {
6666            out.push_str("ufw and iptables not available or insufficient permissions.\n");
6667        }
6668    }
6669
6670    Ok(out.trim_end().to_string())
6671}
6672
6673// ── traceroute ────────────────────────────────────────────────────────────────
6674
6675fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
6676    let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
6677    let hops = max_entries.clamp(5, 30);
6678
6679    #[cfg(target_os = "windows")]
6680    {
6681        let output = Command::new("tracert")
6682            .args(["-d", "-h", &hops.to_string(), host])
6683            .output()
6684            .map_err(|e| format!("tracert: {e}"))?;
6685        let raw = String::from_utf8_lossy(&output.stdout);
6686        let mut hop_count = 0usize;
6687        for line in raw.lines() {
6688            let trimmed = line.trim();
6689            if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
6690                hop_count += 1;
6691                out.push_str(&format!("  {trimmed}\n"));
6692            } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
6693                out.push_str(&format!("{trimmed}\n"));
6694            }
6695        }
6696        if hop_count == 0 {
6697            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6698        }
6699    }
6700
6701    #[cfg(not(target_os = "windows"))]
6702    {
6703        let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
6704            || std::path::Path::new("/usr/sbin/traceroute").exists()
6705        {
6706            "traceroute"
6707        } else {
6708            "tracepath"
6709        };
6710        let output = Command::new(cmd)
6711            .args(["-m", &hops.to_string(), "-n", host])
6712            .output()
6713            .map_err(|e| format!("{cmd}: {e}"))?;
6714        let raw = String::from_utf8_lossy(&output.stdout);
6715        let mut hop_count = 0usize;
6716        for line in raw.lines().take(hops + 2) {
6717            let trimmed = line.trim();
6718            if !trimmed.is_empty() {
6719                hop_count += 1;
6720                out.push_str(&format!("  {trimmed}\n"));
6721            }
6722        }
6723        if hop_count == 0 {
6724            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6725        }
6726    }
6727
6728    Ok(out.trim_end().to_string())
6729}
6730
6731// ── dns_cache ─────────────────────────────────────────────────────────────────
6732
6733fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
6734    let mut out = String::from("Host inspection: dns_cache\n\n");
6735    let n = max_entries.clamp(10, 100);
6736
6737    #[cfg(target_os = "windows")]
6738    {
6739        let output = Command::new("powershell")
6740            .args([
6741                "-NoProfile",
6742                "-Command",
6743                "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
6744            ])
6745            .output()
6746            .map_err(|e| format!("dns_cache: {e}"))?;
6747
6748        let raw = String::from_utf8_lossy(&output.stdout);
6749        let lines: Vec<&str> = raw.lines().skip(1).collect();
6750        let total = lines.len();
6751
6752        if total == 0 {
6753            out.push_str("DNS cache is empty or could not be read.\n");
6754        } else {
6755            out.push_str(&format!(
6756                "DNS cache entries (showing up to {n} of {total}):\n\n"
6757            ));
6758            let mut shown = 0usize;
6759            for line in lines.iter().take(n) {
6760                let cols: Vec<&str> = line.splitn(4, ',').collect();
6761                if cols.len() >= 3 {
6762                    let entry = cols[0].trim_matches('"');
6763                    let rtype = cols[1].trim_matches('"');
6764                    let data = cols[2].trim_matches('"');
6765                    let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
6766                    out.push_str(&format!("  {entry:<45} {rtype:<6} {data}  (TTL {ttl}s)\n"));
6767                    shown += 1;
6768                }
6769            }
6770            if total > shown {
6771                out.push_str(&format!("\n  ... and {} more entries\n", total - shown));
6772            }
6773        }
6774    }
6775
6776    #[cfg(not(target_os = "windows"))]
6777    {
6778        if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
6779            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6780            if !text.is_empty() {
6781                out.push_str("systemd-resolved statistics:\n");
6782                for line in text.lines().take(n) {
6783                    out.push_str(&format!("  {line}\n"));
6784                }
6785                out.push('\n');
6786            }
6787        }
6788        if let Ok(o) = Command::new("dscacheutil")
6789            .args(["-cachedump", "-entries", "Host"])
6790            .output()
6791        {
6792            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6793            if !text.is_empty() {
6794                out.push_str("DNS cache (macOS dscacheutil):\n");
6795                for line in text.lines().take(n) {
6796                    out.push_str(&format!("  {line}\n"));
6797                }
6798            } else {
6799                out.push_str("DNS cache is empty or not accessible on this platform.\n");
6800            }
6801        } else {
6802            out.push_str(
6803                "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
6804            );
6805        }
6806    }
6807
6808    Ok(out.trim_end().to_string())
6809}
6810
6811// ── arp ───────────────────────────────────────────────────────────────────────
6812
6813fn inspect_arp() -> Result<String, String> {
6814    let mut out = String::from("Host inspection: arp\n\n");
6815
6816    #[cfg(target_os = "windows")]
6817    {
6818        let output = Command::new("arp")
6819            .args(["-a"])
6820            .output()
6821            .map_err(|e| format!("arp: {e}"))?;
6822        let raw = String::from_utf8_lossy(&output.stdout);
6823        let mut count = 0usize;
6824        for line in raw.lines() {
6825            let t = line.trim();
6826            if t.is_empty() {
6827                continue;
6828            }
6829            out.push_str(&format!("  {t}\n"));
6830            if t.contains("dynamic") || t.contains("static") {
6831                count += 1;
6832            }
6833        }
6834        out.push_str(&format!("\nTotal entries: {count}\n"));
6835    }
6836
6837    #[cfg(not(target_os = "windows"))]
6838    {
6839        if let Ok(o) = Command::new("arp").args(["-n"]).output() {
6840            let raw = String::from_utf8_lossy(&o.stdout);
6841            let mut count = 0usize;
6842            for line in raw.lines() {
6843                let t = line.trim();
6844                if !t.is_empty() {
6845                    out.push_str(&format!("  {t}\n"));
6846                    count += 1;
6847                }
6848            }
6849            out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
6850        } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
6851            let raw = String::from_utf8_lossy(&o.stdout);
6852            let mut count = 0usize;
6853            for line in raw.lines() {
6854                let t = line.trim();
6855                if !t.is_empty() {
6856                    out.push_str(&format!("  {t}\n"));
6857                    count += 1;
6858                }
6859            }
6860            out.push_str(&format!("\nTotal entries: {count}\n"));
6861        } else {
6862            out.push_str("arp and ip neigh not available.\n");
6863        }
6864    }
6865
6866    Ok(out.trim_end().to_string())
6867}
6868
6869// ── route_table ───────────────────────────────────────────────────────────────
6870
6871fn inspect_route_table(max_entries: usize) -> Result<String, String> {
6872    let mut out = String::from("Host inspection: route_table\n\n");
6873    let n = max_entries.clamp(10, 50);
6874
6875    #[cfg(target_os = "windows")]
6876    {
6877        let script = r#"
6878try {
6879    $routes = Get-NetRoute -ErrorAction Stop |
6880        Where-Object { $_.RouteMetric -lt 9000 } |
6881        Sort-Object RouteMetric |
6882        Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
6883    "TOTAL:" + $routes.Count
6884    $routes | ForEach-Object {
6885        $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
6886    }
6887} catch { "ERROR:" + $_.Exception.Message }
6888"#;
6889        let output = Command::new("powershell")
6890            .args(["-NoProfile", "-Command", script])
6891            .output()
6892            .map_err(|e| format!("route_table: {e}"))?;
6893        let raw = String::from_utf8_lossy(&output.stdout);
6894        let text = raw.trim();
6895
6896        if text.starts_with("ERROR:") {
6897            out.push_str(&format!(
6898                "Unable to read route table: {}\n",
6899                text.trim_start_matches("ERROR:").trim()
6900            ));
6901        } else {
6902            let mut shown = 0usize;
6903            for line in text.lines() {
6904                if let Some(rest) = line.strip_prefix("TOTAL:") {
6905                    let total: usize = rest.trim().parse().unwrap_or(0);
6906                    out.push_str(&format!(
6907                        "Routing table (showing up to {n} of {total} routes):\n\n"
6908                    ));
6909                    out.push_str(&format!(
6910                        "  {:<22} {:<18} {:>8}  Interface\n",
6911                        "Destination", "Next Hop", "Metric"
6912                    ));
6913                    out.push_str(&format!("  {}\n", "-".repeat(70)));
6914                } else if shown < n {
6915                    let parts: Vec<&str> = line.splitn(4, '|').collect();
6916                    if parts.len() == 4 {
6917                        let dest = parts[0];
6918                        let hop =
6919                            if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
6920                                "on-link"
6921                            } else {
6922                                parts[1]
6923                            };
6924                        let metric = parts[2];
6925                        let iface = parts[3];
6926                        out.push_str(&format!("  {dest:<22} {hop:<18} {metric:>8}  {iface}\n"));
6927                        shown += 1;
6928                    }
6929                }
6930            }
6931        }
6932    }
6933
6934    #[cfg(not(target_os = "windows"))]
6935    {
6936        if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
6937            let raw = String::from_utf8_lossy(&o.stdout);
6938            let lines: Vec<&str> = raw.lines().collect();
6939            let total = lines.len();
6940            out.push_str(&format!(
6941                "Routing table (showing up to {n} of {total} routes):\n\n"
6942            ));
6943            for line in lines.iter().take(n) {
6944                out.push_str(&format!("  {line}\n"));
6945            }
6946            if total > n {
6947                out.push_str(&format!("\n  ... and {} more routes\n", total - n));
6948            }
6949        } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
6950            let raw = String::from_utf8_lossy(&o.stdout);
6951            for line in raw.lines().take(n) {
6952                out.push_str(&format!("  {line}\n"));
6953            }
6954        } else {
6955            out.push_str("ip route and netstat not available.\n");
6956        }
6957    }
6958
6959    Ok(out.trim_end().to_string())
6960}
6961
6962// ── env ───────────────────────────────────────────────────────────────────────
6963
6964fn inspect_env(max_entries: usize) -> Result<String, String> {
6965    let mut out = String::from("Host inspection: env\n\n");
6966    let n = max_entries.clamp(10, 50);
6967
6968    fn looks_like_secret(name: &str) -> bool {
6969        let n = name.to_uppercase();
6970        n.contains("KEY")
6971            || n.contains("SECRET")
6972            || n.contains("TOKEN")
6973            || n.contains("PASSWORD")
6974            || n.contains("PASSWD")
6975            || n.contains("CREDENTIAL")
6976            || n.contains("AUTH")
6977            || n.contains("CERT")
6978            || n.contains("PRIVATE")
6979    }
6980
6981    let known_dev_vars: &[&str] = &[
6982        "CARGO_HOME",
6983        "RUSTUP_HOME",
6984        "GOPATH",
6985        "GOROOT",
6986        "GOBIN",
6987        "JAVA_HOME",
6988        "ANDROID_HOME",
6989        "ANDROID_SDK_ROOT",
6990        "PYTHONPATH",
6991        "PYTHONHOME",
6992        "VIRTUAL_ENV",
6993        "CONDA_DEFAULT_ENV",
6994        "CONDA_PREFIX",
6995        "NODE_PATH",
6996        "NVM_DIR",
6997        "NVM_BIN",
6998        "PNPM_HOME",
6999        "DENO_INSTALL",
7000        "DENO_DIR",
7001        "DOTNET_ROOT",
7002        "NUGET_PACKAGES",
7003        "CMAKE_HOME",
7004        "VCPKG_ROOT",
7005        "AWS_PROFILE",
7006        "AWS_REGION",
7007        "AWS_DEFAULT_REGION",
7008        "GCP_PROJECT",
7009        "GOOGLE_CLOUD_PROJECT",
7010        "GOOGLE_APPLICATION_CREDENTIALS",
7011        "AZURE_SUBSCRIPTION_ID",
7012        "DATABASE_URL",
7013        "REDIS_URL",
7014        "MONGO_URI",
7015        "EDITOR",
7016        "VISUAL",
7017        "SHELL",
7018        "TERM",
7019        "XDG_CONFIG_HOME",
7020        "XDG_DATA_HOME",
7021        "XDG_CACHE_HOME",
7022        "HOME",
7023        "USERPROFILE",
7024        "APPDATA",
7025        "LOCALAPPDATA",
7026        "TEMP",
7027        "TMP",
7028        "COMPUTERNAME",
7029        "USERNAME",
7030        "USERDOMAIN",
7031        "PROCESSOR_ARCHITECTURE",
7032        "NUMBER_OF_PROCESSORS",
7033        "OS",
7034        "HOMEDRIVE",
7035        "HOMEPATH",
7036        "HTTP_PROXY",
7037        "HTTPS_PROXY",
7038        "NO_PROXY",
7039        "ALL_PROXY",
7040        "http_proxy",
7041        "https_proxy",
7042        "no_proxy",
7043        "DOCKER_HOST",
7044        "DOCKER_BUILDKIT",
7045        "COMPOSE_PROJECT_NAME",
7046        "KUBECONFIG",
7047        "KUBE_CONTEXT",
7048        "CI",
7049        "GITHUB_ACTIONS",
7050        "GITLAB_CI",
7051        "LMSTUDIO_HOME",
7052        "HEMATITE_URL",
7053    ];
7054
7055    let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
7056    all_vars.sort_by(|a, b| a.0.cmp(&b.0));
7057    let total = all_vars.len();
7058
7059    let mut dev_found: Vec<String> = Vec::new();
7060    let mut secret_found: Vec<String> = Vec::new();
7061
7062    for (k, v) in &all_vars {
7063        if k == "PATH" {
7064            continue;
7065        }
7066        if looks_like_secret(k) {
7067            secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
7068        } else {
7069            let k_upper = k.to_uppercase();
7070            let is_known = known_dev_vars
7071                .iter()
7072                .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
7073            if is_known {
7074                let display = if v.len() > 120 {
7075                    format!("{k} = {}…", &v[..117])
7076                } else {
7077                    format!("{k} = {v}")
7078                };
7079                dev_found.push(display);
7080            }
7081        }
7082    }
7083
7084    out.push_str(&format!("Total environment variables: {total}\n\n"));
7085
7086    if let Ok(p) = std::env::var("PATH") {
7087        let sep = if cfg!(target_os = "windows") {
7088            ';'
7089        } else {
7090            ':'
7091        };
7092        let count = p.split(sep).count();
7093        out.push_str(&format!(
7094            "PATH: {count} entries (use topic=path for full audit)\n\n"
7095        ));
7096    }
7097
7098    if !secret_found.is_empty() {
7099        out.push_str(&format!(
7100            "=== Secret/credential variables ({} detected, values hidden) ===\n",
7101            secret_found.len()
7102        ));
7103        for s in secret_found.iter().take(n) {
7104            out.push_str(&format!("  {s}\n"));
7105        }
7106        out.push('\n');
7107    }
7108
7109    if !dev_found.is_empty() {
7110        out.push_str(&format!(
7111            "=== Developer & tool variables ({}) ===\n",
7112            dev_found.len()
7113        ));
7114        for d in dev_found.iter().take(n) {
7115            out.push_str(&format!("  {d}\n"));
7116        }
7117        out.push('\n');
7118    }
7119
7120    let other_count = all_vars
7121        .iter()
7122        .filter(|(k, _)| {
7123            k != "PATH"
7124                && !looks_like_secret(k)
7125                && !known_dev_vars
7126                    .iter()
7127                    .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
7128        })
7129        .count();
7130    if other_count > 0 {
7131        out.push_str(&format!(
7132            "Other variables: {other_count} (use 'env' in shell to see all)\n"
7133        ));
7134    }
7135
7136    Ok(out.trim_end().to_string())
7137}
7138
7139// ── hosts_file ────────────────────────────────────────────────────────────────
7140
7141fn inspect_hosts_file() -> Result<String, String> {
7142    let mut out = String::from("Host inspection: hosts_file\n\n");
7143
7144    let hosts_path = if cfg!(target_os = "windows") {
7145        std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
7146    } else {
7147        std::path::PathBuf::from("/etc/hosts")
7148    };
7149
7150    out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
7151
7152    match fs::read_to_string(&hosts_path) {
7153        Ok(content) => {
7154            let mut active_entries: Vec<String> = Vec::new();
7155            let mut comment_lines = 0usize;
7156            let mut blank_lines = 0usize;
7157
7158            for line in content.lines() {
7159                let t = line.trim();
7160                if t.is_empty() {
7161                    blank_lines += 1;
7162                } else if t.starts_with('#') {
7163                    comment_lines += 1;
7164                } else {
7165                    active_entries.push(line.to_string());
7166                }
7167            }
7168
7169            out.push_str(&format!(
7170                "Active entries: {}  |  Comment lines: {}  |  Blank lines: {}\n\n",
7171                active_entries.len(),
7172                comment_lines,
7173                blank_lines
7174            ));
7175
7176            if active_entries.is_empty() {
7177                out.push_str(
7178                    "No active host entries (file contains only comments/blanks — standard default state).\n",
7179                );
7180            } else {
7181                out.push_str("=== Active entries ===\n");
7182                for entry in &active_entries {
7183                    out.push_str(&format!("  {entry}\n"));
7184                }
7185                out.push('\n');
7186
7187                let custom: Vec<&String> = active_entries
7188                    .iter()
7189                    .filter(|e| {
7190                        let t = e.trim_start();
7191                        !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
7192                    })
7193                    .collect();
7194                if !custom.is_empty() {
7195                    out.push_str(&format!(
7196                        "[!] Custom (non-loopback) entries: {}\n",
7197                        custom.len()
7198                    ));
7199                    for e in &custom {
7200                        out.push_str(&format!("  {e}\n"));
7201                    }
7202                } else {
7203                    out.push_str("All active entries are standard loopback or block entries.\n");
7204                }
7205            }
7206
7207            out.push_str("\n=== Full file ===\n");
7208            for line in content.lines() {
7209                out.push_str(&format!("  {line}\n"));
7210            }
7211        }
7212        Err(e) => {
7213            out.push_str(&format!("Could not read hosts file: {e}\n"));
7214            if cfg!(target_os = "windows") {
7215                out.push_str(
7216                    "On Windows, run Hematite as Administrator if permission is denied.\n",
7217                );
7218            }
7219        }
7220    }
7221
7222    Ok(out.trim_end().to_string())
7223}
7224
7225// ── docker ────────────────────────────────────────────────────────────────────
7226
7227struct AuditFinding {
7228    finding: String,
7229    impact: String,
7230    fix: String,
7231}
7232
7233#[cfg(target_os = "windows")]
7234#[derive(Debug, Clone)]
7235struct WindowsPnpDevice {
7236    name: String,
7237    status: String,
7238    problem: Option<u64>,
7239    class_name: Option<String>,
7240    instance_id: Option<String>,
7241}
7242
7243#[cfg(target_os = "windows")]
7244#[derive(Debug, Clone)]
7245struct WindowsSoundDevice {
7246    name: String,
7247    status: String,
7248    manufacturer: Option<String>,
7249}
7250
7251struct DockerMountAudit {
7252    mount_type: String,
7253    source: Option<String>,
7254    destination: String,
7255    name: Option<String>,
7256    read_write: Option<bool>,
7257    driver: Option<String>,
7258    exists_on_host: Option<bool>,
7259}
7260
7261struct DockerContainerAudit {
7262    name: String,
7263    image: String,
7264    status: String,
7265    mounts: Vec<DockerMountAudit>,
7266}
7267
7268struct DockerVolumeAudit {
7269    name: String,
7270    driver: String,
7271    mountpoint: Option<String>,
7272    scope: Option<String>,
7273}
7274
7275#[cfg(target_os = "windows")]
7276struct WslDistroAudit {
7277    name: String,
7278    state: String,
7279    version: String,
7280}
7281
7282#[cfg(target_os = "windows")]
7283struct WslRootUsage {
7284    total_kb: u64,
7285    used_kb: u64,
7286    avail_kb: u64,
7287    use_percent: String,
7288    mnt_c_present: Option<bool>,
7289}
7290
7291fn docker_engine_version() -> Result<String, String> {
7292    let version_output = Command::new("docker")
7293        .args(["version", "--format", "{{.Server.Version}}"])
7294        .output();
7295
7296    match version_output {
7297        Err(_) => Err(
7298            "Docker: not found on PATH.\nInstall Docker Desktop: https://www.docker.com/products/docker-desktop".to_string(),
7299        ),
7300        Ok(o) if !o.status.success() => {
7301            let stderr = String::from_utf8_lossy(&o.stderr);
7302            if stderr.contains("cannot connect")
7303                || stderr.contains("Is the docker daemon running")
7304                || stderr.contains("pipe")
7305                || stderr.contains("socket")
7306            {
7307                Err(
7308                    "Docker: installed but daemon is NOT running.\nStart Docker Desktop or run: sudo systemctl start docker".to_string(),
7309                )
7310            } else {
7311                Err(format!("Docker: error - {}", stderr.trim()))
7312            }
7313        }
7314        Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).trim().to_string()),
7315    }
7316}
7317
7318fn parse_docker_mounts(raw: &str) -> Vec<DockerMountAudit> {
7319    let Ok(value) = serde_json::from_str::<Value>(raw.trim()) else {
7320        return Vec::new();
7321    };
7322    let Value::Array(entries) = value else {
7323        return Vec::new();
7324    };
7325
7326    let mut mounts = Vec::new();
7327    for entry in entries {
7328        let mount_type = entry
7329            .get("Type")
7330            .and_then(|v| v.as_str())
7331            .unwrap_or("unknown")
7332            .to_string();
7333        let source = entry
7334            .get("Source")
7335            .and_then(|v| v.as_str())
7336            .map(|v| v.to_string());
7337        let destination = entry
7338            .get("Destination")
7339            .and_then(|v| v.as_str())
7340            .unwrap_or("?")
7341            .to_string();
7342        let name = entry
7343            .get("Name")
7344            .and_then(|v| v.as_str())
7345            .map(|v| v.to_string());
7346        let read_write = entry.get("RW").and_then(|v| v.as_bool());
7347        let driver = entry
7348            .get("Driver")
7349            .and_then(|v| v.as_str())
7350            .map(|v| v.to_string());
7351        let exists_on_host = if mount_type == "bind" {
7352            source.as_deref().map(|path| Path::new(path).exists())
7353        } else {
7354            None
7355        };
7356        mounts.push(DockerMountAudit {
7357            mount_type,
7358            source,
7359            destination,
7360            name,
7361            read_write,
7362            driver,
7363            exists_on_host,
7364        });
7365    }
7366
7367    mounts
7368}
7369
7370fn inspect_docker_volume(name: &str) -> DockerVolumeAudit {
7371    let mut audit = DockerVolumeAudit {
7372        name: name.to_string(),
7373        driver: "unknown".to_string(),
7374        mountpoint: None,
7375        scope: None,
7376    };
7377
7378    if let Ok(output) = Command::new("docker")
7379        .args(["volume", "inspect", name, "--format", "{{json .}}"])
7380        .output()
7381    {
7382        if output.status.success() {
7383            if let Ok(value) =
7384                serde_json::from_str::<Value>(String::from_utf8_lossy(&output.stdout).trim())
7385            {
7386                audit.driver = value
7387                    .get("Driver")
7388                    .and_then(|v| v.as_str())
7389                    .unwrap_or("unknown")
7390                    .to_string();
7391                audit.mountpoint = value
7392                    .get("Mountpoint")
7393                    .and_then(|v| v.as_str())
7394                    .map(|v| v.to_string());
7395                audit.scope = value
7396                    .get("Scope")
7397                    .and_then(|v| v.as_str())
7398                    .map(|v| v.to_string());
7399            }
7400        }
7401    }
7402
7403    audit
7404}
7405
7406#[cfg(target_os = "windows")]
7407fn docker_desktop_disk_image() -> Option<(PathBuf, u64)> {
7408    let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from)?;
7409    for file_name in ["docker_data.vhdx", "ext4.vhdx"] {
7410        let path = local_app_data
7411            .join("Docker")
7412            .join("wsl")
7413            .join("disk")
7414            .join(file_name);
7415        if let Ok(metadata) = fs::metadata(&path) {
7416            return Some((path, metadata.len()));
7417        }
7418    }
7419    None
7420}
7421
7422#[cfg(target_os = "windows")]
7423fn clean_wsl_text(raw: &[u8]) -> String {
7424    String::from_utf8_lossy(raw)
7425        .chars()
7426        .filter(|c| *c != '\0')
7427        .collect()
7428}
7429
7430#[cfg(target_os = "windows")]
7431fn parse_wsl_distros(raw: &str) -> Vec<WslDistroAudit> {
7432    let mut distros = Vec::new();
7433    for line in raw.lines() {
7434        let trimmed = line.trim();
7435        if trimmed.is_empty()
7436            || trimmed.to_uppercase().starts_with("NAME")
7437            || trimmed.starts_with("---")
7438        {
7439            continue;
7440        }
7441        let normalized = trimmed.trim_start_matches('*').trim();
7442        let cols: Vec<&str> = normalized.split_whitespace().collect();
7443        if cols.len() < 3 {
7444            continue;
7445        }
7446        let version = cols[cols.len() - 1].to_string();
7447        let state = cols[cols.len() - 2].to_string();
7448        let name = cols[..cols.len() - 2].join(" ");
7449        if !name.is_empty() {
7450            distros.push(WslDistroAudit {
7451                name,
7452                state,
7453                version,
7454            });
7455        }
7456    }
7457    distros
7458}
7459
7460#[cfg(target_os = "windows")]
7461fn wsl_root_usage(distro_name: &str) -> Option<WslRootUsage> {
7462    let output = Command::new("wsl")
7463        .args([
7464            "-d",
7465            distro_name,
7466            "--",
7467            "sh",
7468            "-lc",
7469            "df -k / 2>/dev/null | tail -n 1; if [ -d /mnt/c ]; then echo __MNTC__:ok; else echo __MNTC__:missing; fi",
7470        ])
7471        .output()
7472        .ok()?;
7473    if !output.status.success() {
7474        return None;
7475    }
7476
7477    let text = clean_wsl_text(&output.stdout);
7478    let mut total_kb = 0;
7479    let mut used_kb = 0;
7480    let mut avail_kb = 0;
7481    let mut use_percent = String::from("unknown");
7482    let mut mnt_c_present = None;
7483
7484    for line in text.lines() {
7485        let trimmed = line.trim();
7486        if trimmed.starts_with("__MNTC__:") {
7487            mnt_c_present = Some(trimmed.ends_with("ok"));
7488            continue;
7489        }
7490        let cols: Vec<&str> = trimmed.split_whitespace().collect();
7491        if cols.len() >= 6 {
7492            total_kb = cols[1].parse::<u64>().unwrap_or(0);
7493            used_kb = cols[2].parse::<u64>().unwrap_or(0);
7494            avail_kb = cols[3].parse::<u64>().unwrap_or(0);
7495            use_percent = cols[4].to_string();
7496        }
7497    }
7498
7499    Some(WslRootUsage {
7500        total_kb,
7501        used_kb,
7502        avail_kb,
7503        use_percent,
7504        mnt_c_present,
7505    })
7506}
7507
7508#[cfg(target_os = "windows")]
7509fn collect_wsl_vhdx_files() -> Vec<(PathBuf, u64)> {
7510    let mut vhds = Vec::new();
7511    let Some(local_app_data) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) else {
7512        return vhds;
7513    };
7514    let packages_dir = local_app_data.join("Packages");
7515    let Ok(entries) = fs::read_dir(packages_dir) else {
7516        return vhds;
7517    };
7518
7519    for entry in entries.flatten() {
7520        let path = entry.path().join("LocalState").join("ext4.vhdx");
7521        if let Ok(metadata) = fs::metadata(&path) {
7522            vhds.push((path, metadata.len()));
7523        }
7524    }
7525    vhds.sort_by(|a, b| b.1.cmp(&a.1));
7526    vhds
7527}
7528
7529fn inspect_docker(max_entries: usize) -> Result<String, String> {
7530    let mut out = String::from("Host inspection: docker\n\n");
7531    let n = max_entries.clamp(5, 25);
7532
7533    let version_output = Command::new("docker")
7534        .args(["version", "--format", "{{.Server.Version}}"])
7535        .output();
7536
7537    match version_output {
7538        Err(_) => {
7539            out.push_str("Docker: not found on PATH.\n");
7540            out.push_str(
7541                "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
7542            );
7543            return Ok(out.trim_end().to_string());
7544        }
7545        Ok(o) if !o.status.success() => {
7546            let stderr = String::from_utf8_lossy(&o.stderr);
7547            if stderr.contains("cannot connect")
7548                || stderr.contains("Is the docker daemon running")
7549                || stderr.contains("pipe")
7550                || stderr.contains("socket")
7551            {
7552                out.push_str("Docker: installed but daemon is NOT running.\n");
7553                out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
7554            } else {
7555                out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
7556            }
7557            return Ok(out.trim_end().to_string());
7558        }
7559        Ok(o) => {
7560            let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
7561            out.push_str(&format!("Docker Engine: {version}\n"));
7562        }
7563    }
7564
7565    if let Ok(o) = Command::new("docker")
7566        .args([
7567            "info",
7568            "--format",
7569            "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
7570        ])
7571        .output()
7572    {
7573        let info = String::from_utf8_lossy(&o.stdout);
7574        for line in info.lines() {
7575            let t = line.trim();
7576            if !t.is_empty() {
7577                out.push_str(&format!("  {t}\n"));
7578            }
7579        }
7580        out.push('\n');
7581    }
7582
7583    if let Ok(o) = Command::new("docker")
7584        .args([
7585            "ps",
7586            "--format",
7587            "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
7588        ])
7589        .output()
7590    {
7591        let raw = String::from_utf8_lossy(&o.stdout);
7592        let lines: Vec<&str> = raw.lines().collect();
7593        if lines.len() <= 1 {
7594            out.push_str("Running containers: none\n\n");
7595        } else {
7596            out.push_str(&format!(
7597                "=== Running containers ({}) ===\n",
7598                lines.len().saturating_sub(1)
7599            ));
7600            for line in lines.iter().take(n + 1) {
7601                out.push_str(&format!("  {line}\n"));
7602            }
7603            if lines.len() > n + 1 {
7604                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
7605            }
7606            out.push('\n');
7607        }
7608    }
7609
7610    if let Ok(o) = Command::new("docker")
7611        .args([
7612            "images",
7613            "--format",
7614            "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
7615        ])
7616        .output()
7617    {
7618        let raw = String::from_utf8_lossy(&o.stdout);
7619        let lines: Vec<&str> = raw.lines().collect();
7620        if lines.len() > 1 {
7621            out.push_str(&format!(
7622                "=== Local images ({}) ===\n",
7623                lines.len().saturating_sub(1)
7624            ));
7625            for line in lines.iter().take(n + 1) {
7626                out.push_str(&format!("  {line}\n"));
7627            }
7628            if lines.len() > n + 1 {
7629                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
7630            }
7631            out.push('\n');
7632        }
7633    }
7634
7635    if let Ok(o) = Command::new("docker")
7636        .args([
7637            "compose",
7638            "ls",
7639            "--format",
7640            "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
7641        ])
7642        .output()
7643    {
7644        let raw = String::from_utf8_lossy(&o.stdout);
7645        let lines: Vec<&str> = raw.lines().collect();
7646        if lines.len() > 1 {
7647            out.push_str(&format!(
7648                "=== Compose projects ({}) ===\n",
7649                lines.len().saturating_sub(1)
7650            ));
7651            for line in lines.iter().take(n + 1) {
7652                out.push_str(&format!("  {line}\n"));
7653            }
7654            out.push('\n');
7655        }
7656    }
7657
7658    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7659        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7660        if !ctx.is_empty() {
7661            out.push_str(&format!("Active context: {ctx}\n"));
7662        }
7663    }
7664
7665    Ok(out.trim_end().to_string())
7666}
7667
7668// ── wsl ───────────────────────────────────────────────────────────────────────
7669
7670fn inspect_docker_filesystems(max_entries: usize) -> Result<String, String> {
7671    let mut out = String::from("Host inspection: docker_filesystems\n\n");
7672    let n = max_entries.clamp(3, 12);
7673
7674    match docker_engine_version() {
7675        Ok(version) => out.push_str(&format!("Docker Engine: {version}\n")),
7676        Err(message) => {
7677            out.push_str(&message);
7678            return Ok(out.trim_end().to_string());
7679        }
7680    }
7681
7682    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
7683        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
7684        if !ctx.is_empty() {
7685            out.push_str(&format!("Active context: {ctx}\n"));
7686        }
7687    }
7688    out.push('\n');
7689
7690    let mut containers = Vec::new();
7691    if let Ok(o) = Command::new("docker")
7692        .args([
7693            "ps",
7694            "-a",
7695            "--format",
7696            "{{.Names}}\t{{.Image}}\t{{.Status}}",
7697        ])
7698        .output()
7699    {
7700        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7701            let cols: Vec<&str> = line.split('\t').collect();
7702            if cols.len() < 3 {
7703                continue;
7704            }
7705            let name = cols[0].trim().to_string();
7706            if name.is_empty() {
7707                continue;
7708            }
7709            let inspect_output = Command::new("docker")
7710                .args(["inspect", &name, "--format", "{{json .Mounts}}"])
7711                .output();
7712            let mounts = match inspect_output {
7713                Ok(result) if result.status.success() => {
7714                    parse_docker_mounts(String::from_utf8_lossy(&result.stdout).trim())
7715                }
7716                _ => Vec::new(),
7717            };
7718            containers.push(DockerContainerAudit {
7719                name,
7720                image: cols[1].trim().to_string(),
7721                status: cols[2].trim().to_string(),
7722                mounts,
7723            });
7724        }
7725    }
7726
7727    let mut volumes = Vec::new();
7728    if let Ok(o) = Command::new("docker")
7729        .args(["volume", "ls", "--format", "{{.Name}}\t{{.Driver}}"])
7730        .output()
7731    {
7732        for line in String::from_utf8_lossy(&o.stdout).lines().take(n) {
7733            let cols: Vec<&str> = line.split('\t').collect();
7734            let Some(name) = cols.first().map(|v| v.trim()).filter(|v| !v.is_empty()) else {
7735                continue;
7736            };
7737            let mut audit = inspect_docker_volume(name);
7738            if audit.driver == "unknown" {
7739                audit.driver = cols
7740                    .get(1)
7741                    .map(|v| v.trim())
7742                    .filter(|v| !v.is_empty())
7743                    .unwrap_or("unknown")
7744                    .to_string();
7745            }
7746            volumes.push(audit);
7747        }
7748    }
7749
7750    let mut findings = Vec::new();
7751    for container in &containers {
7752        for mount in &container.mounts {
7753            if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
7754                let source = mount.source.as_deref().unwrap_or("<unknown>");
7755                findings.push(AuditFinding {
7756                    finding: format!(
7757                        "Container '{}' has a bind mount whose host source is missing: {} -> {}",
7758                        container.name, source, mount.destination
7759                    ),
7760                    impact: "The container may fail to start, or it may see an empty or incomplete directory at the target path.".to_string(),
7761                    fix: "Create the host path or correct the bind source in docker-compose.yml / docker run, then recreate the container.".to_string(),
7762                });
7763            }
7764        }
7765    }
7766
7767    #[cfg(target_os = "windows")]
7768    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
7769        if size_bytes >= 20 * 1024 * 1024 * 1024 {
7770            findings.push(AuditFinding {
7771                finding: format!(
7772                    "Docker Desktop disk image is large: {} at {}",
7773                    human_bytes(size_bytes),
7774                    path.display()
7775                ),
7776                impact: "Unused layers, volumes, and build cache can silently consume Windows disk even after projects are deleted.".to_string(),
7777                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(),
7778            });
7779        }
7780    }
7781
7782    out.push_str("=== Findings ===\n");
7783    if findings.is_empty() {
7784        out.push_str("- Finding: No missing bind-mount sources or oversized Docker Desktop disk images were detected.\n");
7785        out.push_str("  Impact: The Docker host-side filesystem wiring looks sane from this inspection pass.\n");
7786        out.push_str("  Fix: If a workload still cannot see files, compare the mount destinations below against the app's expected paths.\n");
7787    } else {
7788        for finding in &findings {
7789            out.push_str(&format!("- Finding: {}\n", finding.finding));
7790            out.push_str(&format!("  Impact: {}\n", finding.impact));
7791            out.push_str(&format!("  Fix: {}\n", finding.fix));
7792        }
7793    }
7794
7795    out.push_str("\n=== Container mount summary ===\n");
7796    if containers.is_empty() {
7797        out.push_str("- No containers found.\n");
7798    } else {
7799        for container in &containers {
7800            out.push_str(&format!(
7801                "- {} ({}) [{}]\n",
7802                container.name, container.image, container.status
7803            ));
7804            if container.mounts.is_empty() {
7805                out.push_str("  - no mounts reported\n");
7806                continue;
7807            }
7808            for mount in &container.mounts {
7809                let mut source = mount
7810                    .name
7811                    .clone()
7812                    .or_else(|| mount.source.clone())
7813                    .unwrap_or_else(|| "<unknown>".to_string());
7814                if mount.mount_type == "bind" && mount.exists_on_host == Some(false) {
7815                    source.push_str(" [missing]");
7816                }
7817                let mut extras = Vec::new();
7818                if let Some(rw) = mount.read_write {
7819                    extras.push(if rw { "rw" } else { "ro" }.to_string());
7820                }
7821                if let Some(driver) = &mount.driver {
7822                    extras.push(format!("driver={driver}"));
7823                }
7824                let extra_suffix = if extras.is_empty() {
7825                    String::new()
7826                } else {
7827                    format!(" ({})", extras.join(", "))
7828                };
7829                out.push_str(&format!(
7830                    "  - {}: {} -> {}{}\n",
7831                    mount.mount_type, source, mount.destination, extra_suffix
7832                ));
7833            }
7834        }
7835    }
7836
7837    out.push_str("\n=== Named volumes ===\n");
7838    if volumes.is_empty() {
7839        out.push_str("- No named volumes found.\n");
7840    } else {
7841        for volume in &volumes {
7842            let mut detail = format!("- {} (driver: {})", volume.name, volume.driver);
7843            if let Some(scope) = &volume.scope {
7844                detail.push_str(&format!(", scope: {scope}"));
7845            }
7846            if let Some(mountpoint) = &volume.mountpoint {
7847                detail.push_str(&format!(", mountpoint: {mountpoint}"));
7848            }
7849            out.push_str(&format!("{detail}\n"));
7850        }
7851    }
7852
7853    #[cfg(target_os = "windows")]
7854    if let Some((path, size_bytes)) = docker_desktop_disk_image() {
7855        out.push_str("\n=== Docker Desktop disk ===\n");
7856        out.push_str(&format!(
7857            "- {} at {}\n",
7858            human_bytes(size_bytes),
7859            path.display()
7860        ));
7861    }
7862
7863    Ok(out.trim_end().to_string())
7864}
7865
7866fn inspect_wsl() -> Result<String, String> {
7867    let mut out = String::from("Host inspection: wsl\n\n");
7868
7869    #[cfg(target_os = "windows")]
7870    {
7871        if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
7872            let raw = String::from_utf8_lossy(&o.stdout);
7873            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
7874            for line in cleaned.lines().take(4) {
7875                let t = line.trim();
7876                if !t.is_empty() {
7877                    out.push_str(&format!("  {t}\n"));
7878                }
7879            }
7880            out.push('\n');
7881        }
7882
7883        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
7884        match list_output {
7885            Err(e) => {
7886                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
7887                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
7888            }
7889            Ok(o) if !o.status.success() => {
7890                let stderr = String::from_utf8_lossy(&o.stderr);
7891                let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
7892                out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
7893                out.push_str("Run: wsl --install\n");
7894            }
7895            Ok(o) => {
7896                let raw = String::from_utf8_lossy(&o.stdout);
7897                let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
7898                let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
7899                let distro_lines: Vec<&str> = lines
7900                    .iter()
7901                    .filter(|l| {
7902                        let t = l.trim();
7903                        !t.is_empty()
7904                            && !t.to_uppercase().starts_with("NAME")
7905                            && !t.starts_with("---")
7906                    })
7907                    .copied()
7908                    .collect();
7909
7910                if distro_lines.is_empty() {
7911                    out.push_str("WSL: installed but no distributions found.\n");
7912                    out.push_str("Install a distro: wsl --install -d Ubuntu\n");
7913                } else {
7914                    out.push_str("=== WSL Distributions ===\n");
7915                    for line in &lines {
7916                        out.push_str(&format!("  {}\n", line.trim()));
7917                    }
7918                    out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
7919                }
7920            }
7921        }
7922
7923        if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
7924            let raw = String::from_utf8_lossy(&o.stdout);
7925            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
7926            let status_lines: Vec<&str> = cleaned
7927                .lines()
7928                .filter(|l| !l.trim().is_empty())
7929                .take(8)
7930                .collect();
7931            if !status_lines.is_empty() {
7932                out.push_str("\n=== WSL status ===\n");
7933                for line in status_lines {
7934                    out.push_str(&format!("  {}\n", line.trim()));
7935                }
7936            }
7937        }
7938    }
7939
7940    #[cfg(not(target_os = "windows"))]
7941    {
7942        out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
7943        out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
7944    }
7945
7946    Ok(out.trim_end().to_string())
7947}
7948
7949// ── ssh ───────────────────────────────────────────────────────────────────────
7950
7951fn inspect_wsl_filesystems(max_entries: usize) -> Result<String, String> {
7952    let mut out = String::from("Host inspection: wsl_filesystems\n\n");
7953
7954    #[cfg(target_os = "windows")]
7955    {
7956        let n = max_entries.clamp(3, 12);
7957        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
7958        let distros = match list_output {
7959            Err(e) => {
7960                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
7961                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
7962                return Ok(out.trim_end().to_string());
7963            }
7964            Ok(o) if !o.status.success() => {
7965                let cleaned = clean_wsl_text(&o.stderr);
7966                out.push_str(&format!("WSL: error - {}\n", cleaned.trim()));
7967                out.push_str("Run: wsl --install\n");
7968                return Ok(out.trim_end().to_string());
7969            }
7970            Ok(o) => parse_wsl_distros(&clean_wsl_text(&o.stdout)),
7971        };
7972
7973        out.push_str(&format!("Distributions detected: {}\n\n", distros.len()));
7974
7975        let vhdx_files = collect_wsl_vhdx_files();
7976        let mut findings = Vec::new();
7977        let mut live_usage = Vec::new();
7978
7979        for distro in distros.iter().take(n) {
7980            if distro.state.eq_ignore_ascii_case("Running") {
7981                if let Some(usage) = wsl_root_usage(&distro.name) {
7982                    if let Some(false) = usage.mnt_c_present {
7983                        findings.push(AuditFinding {
7984                            finding: format!(
7985                                "Distro '{}' is running without /mnt/c available",
7986                                distro.name
7987                            ),
7988                            impact: "Windows to WSL path bridging is broken, so projects under C:\\ may not be reachable from Linux tools.".to_string(),
7989                            fix: "Check /etc/wsl.conf automount settings, restart WSL with `wsl --shutdown`, then confirm drvfs automount is enabled.".to_string(),
7990                        });
7991                    }
7992
7993                    let percent_num = usage
7994                        .use_percent
7995                        .trim_end_matches('%')
7996                        .parse::<u32>()
7997                        .unwrap_or(0);
7998                    if percent_num >= 85 {
7999                        findings.push(AuditFinding {
8000                            finding: format!(
8001                                "Distro '{}' root filesystem is {} full",
8002                                distro.name, usage.use_percent
8003                            ),
8004                            impact: "Package installs, git checkouts, and build caches inside WSL can start failing even when Windows still has free space.".to_string(),
8005                            fix: "Free space inside the distro first, then shut WSL down and compact the VHDX if the host-side file stays large.".to_string(),
8006                        });
8007                    }
8008                    live_usage.push((distro.name.clone(), usage));
8009                }
8010            }
8011        }
8012
8013        for (path, size_bytes) in vhdx_files.iter().take(n) {
8014            if *size_bytes >= 20 * 1024 * 1024 * 1024 {
8015                findings.push(AuditFinding {
8016                    finding: format!(
8017                        "Host-side WSL disk image is large: {} at {}",
8018                        human_bytes(*size_bytes),
8019                        path.display()
8020                    ),
8021                    impact: "Sparse VHDX files can keep consuming Windows disk after files are deleted inside the distro.".to_string(),
8022                    fix: "Clean files inside the distro first, then shut WSL down and compact the VHDX with your normal maintenance workflow.".to_string(),
8023                });
8024            }
8025        }
8026
8027        out.push_str("=== Findings ===\n");
8028        if findings.is_empty() {
8029            out.push_str("- Finding: No oversized WSL disk images or broken /mnt/c bridge mounts were detected in the sampled distros.\n");
8030            out.push_str("  Impact: WSL storage and Windows path bridging look healthy from this inspection pass.\n");
8031            out.push_str("  Fix: If a specific project path still fails, inspect the per-distro bridge and disk details below.\n");
8032        } else {
8033            for finding in &findings {
8034                out.push_str(&format!("- Finding: {}\n", finding.finding));
8035                out.push_str(&format!("  Impact: {}\n", finding.impact));
8036                out.push_str(&format!("  Fix: {}\n", finding.fix));
8037            }
8038        }
8039
8040        out.push_str("\n=== Distro bridge and root usage ===\n");
8041        if distros.is_empty() {
8042            out.push_str("- No WSL distributions found.\n");
8043        } else {
8044            for distro in distros.iter().take(n) {
8045                out.push_str(&format!(
8046                    "- {} [state: {}, version: {}]\n",
8047                    distro.name, distro.state, distro.version
8048                ));
8049                if let Some((_, usage)) = live_usage.iter().find(|(name, _)| name == &distro.name) {
8050                    out.push_str(&format!(
8051                        "  - rootfs: {} used / {} total ({}), free: {}\n",
8052                        human_bytes(usage.used_kb * 1024),
8053                        human_bytes(usage.total_kb * 1024),
8054                        usage.use_percent,
8055                        human_bytes(usage.avail_kb * 1024)
8056                    ));
8057                    match usage.mnt_c_present {
8058                        Some(true) => out.push_str("  - /mnt/c bridge: present\n"),
8059                        Some(false) => out.push_str("  - /mnt/c bridge: missing\n"),
8060                        None => out.push_str("  - /mnt/c bridge: unknown\n"),
8061                    }
8062                } else if distro.state.eq_ignore_ascii_case("Running") {
8063                    out.push_str("  - live rootfs check: unavailable\n");
8064                } else {
8065                    out.push_str(
8066                        "  - live rootfs check: skipped to avoid starting a stopped distro\n",
8067                    );
8068                }
8069            }
8070        }
8071
8072        out.push_str("\n=== Host-side VHDX files ===\n");
8073        if vhdx_files.is_empty() {
8074            out.push_str("- No ext4.vhdx files found under %LOCALAPPDATA%\\Packages. Imported distros may live elsewhere.\n");
8075        } else {
8076            for (path, size_bytes) in vhdx_files.iter().take(n) {
8077                out.push_str(&format!(
8078                    "- {} at {}\n",
8079                    human_bytes(*size_bytes),
8080                    path.display()
8081                ));
8082            }
8083        }
8084    }
8085
8086    #[cfg(not(target_os = "windows"))]
8087    {
8088        let _ = max_entries;
8089        out.push_str("WSL filesystem auditing is a Windows-only inspection.\n");
8090        out.push_str("On Linux/macOS, use native VM/container storage inspection instead.\n");
8091    }
8092
8093    Ok(out.trim_end().to_string())
8094}
8095
8096fn dirs_home() -> Option<PathBuf> {
8097    std::env::var("HOME")
8098        .ok()
8099        .map(PathBuf::from)
8100        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
8101}
8102
8103fn inspect_ssh() -> Result<String, String> {
8104    let mut out = String::from("Host inspection: ssh\n\n");
8105
8106    if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
8107        let ver = if o.stdout.is_empty() {
8108            String::from_utf8_lossy(&o.stderr).trim().to_string()
8109        } else {
8110            String::from_utf8_lossy(&o.stdout).trim().to_string()
8111        };
8112        if !ver.is_empty() {
8113            out.push_str(&format!("SSH client: {ver}\n"));
8114        }
8115    } else {
8116        out.push_str("SSH client: not found on PATH.\n");
8117    }
8118
8119    #[cfg(target_os = "windows")]
8120    {
8121        let script = r#"
8122$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
8123if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
8124else { "SSHD:not_installed" }
8125"#;
8126        if let Ok(o) = Command::new("powershell")
8127            .args(["-NoProfile", "-Command", script])
8128            .output()
8129        {
8130            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8131            if text.contains("not_installed") {
8132                out.push_str("SSH server (sshd): not installed\n");
8133            } else {
8134                out.push_str(&format!(
8135                    "SSH server (sshd): {}\n",
8136                    text.trim_start_matches("SSHD:")
8137                ));
8138            }
8139        }
8140    }
8141
8142    #[cfg(not(target_os = "windows"))]
8143    {
8144        if let Ok(o) = Command::new("systemctl")
8145            .args(["is-active", "sshd"])
8146            .output()
8147        {
8148            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8149            out.push_str(&format!("SSH server (sshd): {status}\n"));
8150        } else if let Ok(o) = Command::new("systemctl")
8151            .args(["is-active", "ssh"])
8152            .output()
8153        {
8154            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
8155            out.push_str(&format!("SSH server (ssh): {status}\n"));
8156        }
8157    }
8158
8159    out.push('\n');
8160
8161    if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
8162        if ssh_dir.exists() {
8163            out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
8164
8165            let kh = ssh_dir.join("known_hosts");
8166            if kh.exists() {
8167                let count = fs::read_to_string(&kh)
8168                    .map(|c| {
8169                        c.lines()
8170                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8171                            .count()
8172                    })
8173                    .unwrap_or(0);
8174                out.push_str(&format!("  known_hosts: {count} entries\n"));
8175            } else {
8176                out.push_str("  known_hosts: not present\n");
8177            }
8178
8179            let ak = ssh_dir.join("authorized_keys");
8180            if ak.exists() {
8181                let count = fs::read_to_string(&ak)
8182                    .map(|c| {
8183                        c.lines()
8184                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
8185                            .count()
8186                    })
8187                    .unwrap_or(0);
8188                out.push_str(&format!("  authorized_keys: {count} public keys\n"));
8189            } else {
8190                out.push_str("  authorized_keys: not present\n");
8191            }
8192
8193            let key_names = [
8194                "id_rsa",
8195                "id_ed25519",
8196                "id_ecdsa",
8197                "id_dsa",
8198                "id_ecdsa_sk",
8199                "id_ed25519_sk",
8200            ];
8201            let found_keys: Vec<&str> = key_names
8202                .iter()
8203                .filter(|k| ssh_dir.join(k).exists())
8204                .copied()
8205                .collect();
8206            if !found_keys.is_empty() {
8207                out.push_str(&format!("  Private keys: {}\n", found_keys.join(", ")));
8208            } else {
8209                out.push_str("  Private keys: none found\n");
8210            }
8211
8212            let config_path = ssh_dir.join("config");
8213            if config_path.exists() {
8214                out.push_str("\n=== SSH config hosts ===\n");
8215                match fs::read_to_string(&config_path) {
8216                    Ok(content) => {
8217                        let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
8218                        let mut current: Option<(String, Vec<String>)> = None;
8219                        for line in content.lines() {
8220                            let t = line.trim();
8221                            if t.is_empty() || t.starts_with('#') {
8222                                continue;
8223                            }
8224                            if let Some(host) = t.strip_prefix("Host ") {
8225                                if let Some(prev) = current.take() {
8226                                    hosts.push(prev);
8227                                }
8228                                current = Some((host.trim().to_string(), Vec::new()));
8229                            } else if let Some((_, ref mut details)) = current {
8230                                let tu = t.to_uppercase();
8231                                if tu.starts_with("HOSTNAME ")
8232                                    || tu.starts_with("USER ")
8233                                    || tu.starts_with("PORT ")
8234                                    || tu.starts_with("IDENTITYFILE ")
8235                                {
8236                                    details.push(t.to_string());
8237                                }
8238                            }
8239                        }
8240                        if let Some(prev) = current {
8241                            hosts.push(prev);
8242                        }
8243
8244                        if hosts.is_empty() {
8245                            out.push_str("  No Host entries found.\n");
8246                        } else {
8247                            for (h, details) in &hosts {
8248                                if details.is_empty() {
8249                                    out.push_str(&format!("  Host {h}\n"));
8250                                } else {
8251                                    out.push_str(&format!(
8252                                        "  Host {h}  [{}]\n",
8253                                        details.join(", ")
8254                                    ));
8255                                }
8256                            }
8257                            out.push_str(&format!("\n  Total configured hosts: {}\n", hosts.len()));
8258                        }
8259                    }
8260                    Err(e) => out.push_str(&format!("  Could not read config: {e}\n")),
8261                }
8262            } else {
8263                out.push_str("  SSH config: not present\n");
8264            }
8265        } else {
8266            out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
8267        }
8268    }
8269
8270    Ok(out.trim_end().to_string())
8271}
8272
8273// ── installed_software ────────────────────────────────────────────────────────
8274
8275fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
8276    let mut out = String::from("Host inspection: installed_software\n\n");
8277    let n = max_entries.clamp(10, 50);
8278
8279    #[cfg(target_os = "windows")]
8280    {
8281        let winget_out = Command::new("winget")
8282            .args(["list", "--accept-source-agreements"])
8283            .output();
8284
8285        if let Ok(o) = winget_out {
8286            if o.status.success() {
8287                let raw = String::from_utf8_lossy(&o.stdout);
8288                let mut header_done = false;
8289                let mut packages: Vec<&str> = Vec::new();
8290                for line in raw.lines() {
8291                    let t = line.trim();
8292                    if t.starts_with("---") {
8293                        header_done = true;
8294                        continue;
8295                    }
8296                    if header_done && !t.is_empty() {
8297                        packages.push(line);
8298                    }
8299                }
8300                let total = packages.len();
8301                out.push_str(&format!(
8302                    "=== Installed software via winget ({total} packages) ===\n\n"
8303                ));
8304                for line in packages.iter().take(n) {
8305                    out.push_str(&format!("  {line}\n"));
8306                }
8307                if total > n {
8308                    out.push_str(&format!("\n  ... and {} more packages\n", total - n));
8309                }
8310                out.push_str("\nFor full list: winget list\n");
8311                return Ok(out.trim_end().to_string());
8312            }
8313        }
8314
8315        // Fallback: registry scan
8316        let script = format!(
8317            r#"
8318$apps = @()
8319$reg_paths = @(
8320    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
8321    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
8322    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
8323)
8324foreach ($p in $reg_paths) {{
8325    try {{
8326        $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
8327            Where-Object {{ $_.DisplayName }} |
8328            Select-Object DisplayName, DisplayVersion, Publisher
8329    }} catch {{}}
8330}}
8331$sorted = $apps | Sort-Object DisplayName -Unique
8332"TOTAL:" + $sorted.Count
8333$sorted | Select-Object -First {n} | ForEach-Object {{
8334    $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
8335}}
8336"#
8337        );
8338        if let Ok(o) = Command::new("powershell")
8339            .args(["-NoProfile", "-Command", &script])
8340            .output()
8341        {
8342            let raw = String::from_utf8_lossy(&o.stdout);
8343            out.push_str("=== Installed software (registry scan) ===\n");
8344            out.push_str(&format!("  {:<50} {:<18} Publisher\n", "Name", "Version"));
8345            out.push_str(&format!("  {}\n", "-".repeat(90)));
8346            for line in raw.lines() {
8347                if let Some(rest) = line.strip_prefix("TOTAL:") {
8348                    let total: usize = rest.trim().parse().unwrap_or(0);
8349                    out.push_str(&format!("  (Total: {total}, showing first {n})\n\n"));
8350                } else if !line.trim().is_empty() {
8351                    let parts: Vec<&str> = line.splitn(3, '|').collect();
8352                    let name = parts.first().map(|s| s.trim()).unwrap_or("");
8353                    let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
8354                    let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
8355                    out.push_str(&format!("  {:<50} {:<18} {pub_}\n", name, ver));
8356                }
8357            }
8358        } else {
8359            out.push_str(
8360                "Could not query installed software (winget and registry scan both failed).\n",
8361            );
8362        }
8363    }
8364
8365    #[cfg(target_os = "linux")]
8366    {
8367        let mut found = false;
8368        if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
8369            if o.status.success() {
8370                let raw = String::from_utf8_lossy(&o.stdout);
8371                let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
8372                let total = installed.len();
8373                out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
8374                for line in installed.iter().take(n) {
8375                    out.push_str(&format!("  {}\n", line.trim()));
8376                }
8377                if total > n {
8378                    out.push_str(&format!("  ... and {} more\n", total - n));
8379                }
8380                out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
8381                found = true;
8382            }
8383        }
8384        if !found {
8385            if let Ok(o) = Command::new("rpm")
8386                .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
8387                .output()
8388            {
8389                if o.status.success() {
8390                    let raw = String::from_utf8_lossy(&o.stdout);
8391                    let lines: Vec<&str> = raw.lines().collect();
8392                    let total = lines.len();
8393                    out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
8394                    for line in lines.iter().take(n) {
8395                        out.push_str(&format!("  {line}\n"));
8396                    }
8397                    if total > n {
8398                        out.push_str(&format!("  ... and {} more\n", total - n));
8399                    }
8400                    found = true;
8401                }
8402            }
8403        }
8404        if !found {
8405            if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
8406                if o.status.success() {
8407                    let raw = String::from_utf8_lossy(&o.stdout);
8408                    let lines: Vec<&str> = raw.lines().collect();
8409                    let total = lines.len();
8410                    out.push_str(&format!(
8411                        "=== Installed packages via pacman ({total}) ===\n"
8412                    ));
8413                    for line in lines.iter().take(n) {
8414                        out.push_str(&format!("  {line}\n"));
8415                    }
8416                    if total > n {
8417                        out.push_str(&format!("  ... and {} more\n", total - n));
8418                    }
8419                    found = true;
8420                }
8421            }
8422        }
8423        if !found {
8424            out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
8425        }
8426    }
8427
8428    #[cfg(target_os = "macos")]
8429    {
8430        if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
8431            if o.status.success() {
8432                let raw = String::from_utf8_lossy(&o.stdout);
8433                let lines: Vec<&str> = raw.lines().collect();
8434                let total = lines.len();
8435                out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
8436                for line in lines.iter().take(n) {
8437                    out.push_str(&format!("  {line}\n"));
8438                }
8439                if total > n {
8440                    out.push_str(&format!("  ... and {} more\n", total - n));
8441                }
8442                out.push_str("\nFor full list: brew list --versions\n");
8443            }
8444        } else {
8445            out.push_str("Homebrew not found.\n");
8446        }
8447        if let Ok(o) = Command::new("mas").args(["list"]).output() {
8448            if o.status.success() {
8449                let raw = String::from_utf8_lossy(&o.stdout);
8450                let lines: Vec<&str> = raw.lines().collect();
8451                out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
8452                for line in lines.iter().take(n) {
8453                    out.push_str(&format!("  {line}\n"));
8454                }
8455            }
8456        }
8457    }
8458
8459    Ok(out.trim_end().to_string())
8460}
8461
8462// ── git_config ────────────────────────────────────────────────────────────────
8463
8464fn inspect_git_config() -> Result<String, String> {
8465    let mut out = String::from("Host inspection: git_config\n\n");
8466
8467    if let Ok(o) = Command::new("git").args(["--version"]).output() {
8468        let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
8469        out.push_str(&format!("Git: {ver}\n\n"));
8470    } else {
8471        out.push_str("Git: not found on PATH.\n");
8472        return Ok(out.trim_end().to_string());
8473    }
8474
8475    if let Ok(o) = Command::new("git")
8476        .args(["config", "--global", "--list"])
8477        .output()
8478    {
8479        if o.status.success() {
8480            let raw = String::from_utf8_lossy(&o.stdout);
8481            let mut pairs: Vec<(String, String)> = raw
8482                .lines()
8483                .filter_map(|l| {
8484                    let mut parts = l.splitn(2, '=');
8485                    let k = parts.next()?.trim().to_string();
8486                    let v = parts.next().unwrap_or("").trim().to_string();
8487                    Some((k, v))
8488                })
8489                .collect();
8490            pairs.sort_by(|a, b| a.0.cmp(&b.0));
8491
8492            out.push_str("=== Global git config ===\n");
8493
8494            let sections: &[(&str, &[&str])] = &[
8495                ("Identity", &["user.name", "user.email", "user.signingkey"]),
8496                (
8497                    "Core",
8498                    &[
8499                        "core.editor",
8500                        "core.autocrlf",
8501                        "core.eol",
8502                        "core.ignorecase",
8503                        "core.filemode",
8504                    ],
8505                ),
8506                (
8507                    "Commit/Signing",
8508                    &[
8509                        "commit.gpgsign",
8510                        "tag.gpgsign",
8511                        "gpg.format",
8512                        "gpg.ssh.allowedsignersfile",
8513                    ],
8514                ),
8515                (
8516                    "Push/Pull",
8517                    &[
8518                        "push.default",
8519                        "push.autosetupremote",
8520                        "pull.rebase",
8521                        "pull.ff",
8522                    ],
8523                ),
8524                ("Credential", &["credential.helper"]),
8525                ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
8526            ];
8527
8528            let mut shown_keys: HashSet<String> = HashSet::new();
8529            for (section, keys) in sections {
8530                let mut section_lines: Vec<String> = Vec::new();
8531                for key in *keys {
8532                    if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
8533                        section_lines.push(format!("  {k} = {v}"));
8534                        shown_keys.insert(k.clone());
8535                    }
8536                }
8537                if !section_lines.is_empty() {
8538                    out.push_str(&format!("\n[{section}]\n"));
8539                    for line in section_lines {
8540                        out.push_str(&format!("{line}\n"));
8541                    }
8542                }
8543            }
8544
8545            let other: Vec<&(String, String)> = pairs
8546                .iter()
8547                .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
8548                .collect();
8549            if !other.is_empty() {
8550                out.push_str("\n[Other]\n");
8551                for (k, v) in other.iter().take(20) {
8552                    out.push_str(&format!("  {k} = {v}\n"));
8553                }
8554                if other.len() > 20 {
8555                    out.push_str(&format!("  ... and {} more\n", other.len() - 20));
8556                }
8557            }
8558
8559            out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
8560        } else {
8561            out.push_str("No global git config found.\n");
8562            out.push_str("Set up with:\n");
8563            out.push_str("  git config --global user.name \"Your Name\"\n");
8564            out.push_str("  git config --global user.email \"you@example.com\"\n");
8565        }
8566    }
8567
8568    if let Ok(o) = Command::new("git")
8569        .args(["config", "--local", "--list"])
8570        .output()
8571    {
8572        if o.status.success() {
8573            let raw = String::from_utf8_lossy(&o.stdout);
8574            let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8575            if !lines.is_empty() {
8576                out.push_str(&format!(
8577                    "\n=== Local repo config ({} keys) ===\n",
8578                    lines.len()
8579                ));
8580                for line in lines.iter().take(15) {
8581                    out.push_str(&format!("  {line}\n"));
8582                }
8583                if lines.len() > 15 {
8584                    out.push_str(&format!("  ... and {} more\n", lines.len() - 15));
8585                }
8586            }
8587        }
8588    }
8589
8590    if let Ok(o) = Command::new("git")
8591        .args(["config", "--global", "--get-regexp", r"alias\."])
8592        .output()
8593    {
8594        if o.status.success() {
8595            let raw = String::from_utf8_lossy(&o.stdout);
8596            let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
8597            if !aliases.is_empty() {
8598                out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
8599                for a in aliases.iter().take(20) {
8600                    out.push_str(&format!("  {a}\n"));
8601                }
8602                if aliases.len() > 20 {
8603                    out.push_str(&format!("  ... and {} more\n", aliases.len() - 20));
8604                }
8605            }
8606        }
8607    }
8608
8609    Ok(out.trim_end().to_string())
8610}
8611
8612// ── databases ─────────────────────────────────────────────────────────────────
8613
8614fn inspect_databases() -> Result<String, String> {
8615    let mut out = String::from("Host inspection: databases\n\n");
8616    out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
8617
8618    struct DbEngine {
8619        name: &'static str,
8620        service_names: &'static [&'static str],
8621        default_port: u16,
8622        cli_name: &'static str,
8623        cli_version_args: &'static [&'static str],
8624    }
8625
8626    let engines: &[DbEngine] = &[
8627        DbEngine {
8628            name: "PostgreSQL",
8629            service_names: &[
8630                "postgresql",
8631                "postgresql-x64-14",
8632                "postgresql-x64-15",
8633                "postgresql-x64-16",
8634                "postgresql-x64-17",
8635            ],
8636
8637            default_port: 5432,
8638            cli_name: "psql",
8639            cli_version_args: &["--version"],
8640        },
8641        DbEngine {
8642            name: "MySQL",
8643            service_names: &["mysql", "mysql80", "mysql57"],
8644
8645            default_port: 3306,
8646            cli_name: "mysql",
8647            cli_version_args: &["--version"],
8648        },
8649        DbEngine {
8650            name: "MariaDB",
8651            service_names: &["mariadb", "mariadb.exe"],
8652
8653            default_port: 3306,
8654            cli_name: "mariadb",
8655            cli_version_args: &["--version"],
8656        },
8657        DbEngine {
8658            name: "MongoDB",
8659            service_names: &["mongodb", "mongod"],
8660
8661            default_port: 27017,
8662            cli_name: "mongod",
8663            cli_version_args: &["--version"],
8664        },
8665        DbEngine {
8666            name: "Redis",
8667            service_names: &["redis", "redis-server"],
8668
8669            default_port: 6379,
8670            cli_name: "redis-server",
8671            cli_version_args: &["--version"],
8672        },
8673        DbEngine {
8674            name: "SQL Server",
8675            service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
8676
8677            default_port: 1433,
8678            cli_name: "sqlcmd",
8679            cli_version_args: &["-?"],
8680        },
8681        DbEngine {
8682            name: "SQLite",
8683            service_names: &[], // no service — file-based
8684
8685            default_port: 0, // no port — file-based
8686            cli_name: "sqlite3",
8687            cli_version_args: &["--version"],
8688        },
8689        DbEngine {
8690            name: "CouchDB",
8691            service_names: &["couchdb", "apache-couchdb"],
8692
8693            default_port: 5984,
8694            cli_name: "couchdb",
8695            cli_version_args: &["--version"],
8696        },
8697        DbEngine {
8698            name: "Cassandra",
8699            service_names: &["cassandra"],
8700
8701            default_port: 9042,
8702            cli_name: "cqlsh",
8703            cli_version_args: &["--version"],
8704        },
8705        DbEngine {
8706            name: "Elasticsearch",
8707            service_names: &["elasticsearch-service-x64", "elasticsearch"],
8708
8709            default_port: 9200,
8710            cli_name: "elasticsearch",
8711            cli_version_args: &["--version"],
8712        },
8713    ];
8714
8715    // Helper: check if port is listening
8716    fn port_listening(port: u16) -> bool {
8717        if port == 0 {
8718            return false;
8719        }
8720        // Use netstat-style check via connecting
8721        std::net::TcpStream::connect_timeout(
8722            &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
8723            std::time::Duration::from_millis(150),
8724        )
8725        .is_ok()
8726    }
8727
8728    let mut found_any = false;
8729
8730    for engine in engines {
8731        let mut status_parts: Vec<String> = Vec::new();
8732        let mut detected = false;
8733
8734        // 1. CLI version check (fastest — works cross-platform)
8735        let version = Command::new(engine.cli_name)
8736            .args(engine.cli_version_args)
8737            .output()
8738            .ok()
8739            .and_then(|o| {
8740                let combined = if o.stdout.is_empty() {
8741                    String::from_utf8_lossy(&o.stderr).trim().to_string()
8742                } else {
8743                    String::from_utf8_lossy(&o.stdout).trim().to_string()
8744                };
8745                // Take just the first line
8746                combined.lines().next().map(|l| l.trim().to_string())
8747            });
8748
8749        if let Some(ref ver) = version {
8750            if !ver.is_empty() {
8751                status_parts.push(format!("version: {ver}"));
8752                detected = true;
8753            }
8754        }
8755
8756        // 2. Port check
8757        if engine.default_port > 0 && port_listening(engine.default_port) {
8758            status_parts.push(format!("listening on :{}", engine.default_port));
8759            detected = true;
8760        } else if engine.default_port > 0 && detected {
8761            status_parts.push(format!("not listening on :{}", engine.default_port));
8762        }
8763
8764        // 3. Windows service check
8765        #[cfg(target_os = "windows")]
8766        {
8767            if !engine.service_names.is_empty() {
8768                let service_list = engine.service_names.join("','");
8769                let script = format!(
8770                    r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
8771                    service_list
8772                );
8773                if let Ok(o) = Command::new("powershell")
8774                    .args(["-NoProfile", "-Command", &script])
8775                    .output()
8776                {
8777                    let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
8778                    if !text.is_empty() {
8779                        let parts: Vec<&str> = text.splitn(2, ':').collect();
8780                        let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
8781                        let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
8782                        status_parts.push(format!("service '{svc_name}': {svc_state}"));
8783                        detected = true;
8784                    }
8785                }
8786            }
8787        }
8788
8789        // 4. Linux/macOS systemctl / launchctl check
8790        #[cfg(not(target_os = "windows"))]
8791        {
8792            for svc in engine.service_names {
8793                if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
8794                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
8795                    if !state.is_empty() && state != "inactive" {
8796                        status_parts.push(format!("systemd '{svc}': {state}"));
8797                        detected = true;
8798                        break;
8799                    }
8800                }
8801            }
8802        }
8803
8804        if detected {
8805            found_any = true;
8806            let label = if engine.default_port > 0 {
8807                format!("{} (default port: {})", engine.name, engine.default_port)
8808            } else {
8809                format!("{} (file-based, no port)", engine.name)
8810            };
8811            out.push_str(&format!("[FOUND] {label}\n"));
8812            for part in &status_parts {
8813                out.push_str(&format!("  {part}\n"));
8814            }
8815            out.push('\n');
8816        }
8817    }
8818
8819    if !found_any {
8820        out.push_str("No local database engines detected.\n");
8821        out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
8822        out.push_str(
8823            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
8824        );
8825    } else {
8826        out.push_str("---\n");
8827        out.push_str(
8828            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
8829        );
8830        out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
8831    }
8832
8833    Ok(out.trim_end().to_string())
8834}
8835
8836// ── user_accounts ─────────────────────────────────────────────────────────────
8837
8838fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
8839    let mut out = String::from("Host inspection: user_accounts\n\n");
8840
8841    #[cfg(target_os = "windows")]
8842    {
8843        let users_out = Command::new("powershell")
8844            .args([
8845                "-NoProfile", "-NonInteractive", "-Command",
8846                "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \"  $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
8847            ])
8848            .output()
8849            .ok()
8850            .and_then(|o| String::from_utf8(o.stdout).ok())
8851            .unwrap_or_default();
8852
8853        out.push_str("=== Local User Accounts ===\n");
8854        if users_out.trim().is_empty() {
8855            out.push_str("  (requires elevation or Get-LocalUser unavailable)\n");
8856        } else {
8857            for line in users_out.lines().take(max_entries) {
8858                if !line.trim().is_empty() {
8859                    out.push_str(line);
8860                    out.push('\n');
8861                }
8862            }
8863        }
8864
8865        let admins_out = Command::new("powershell")
8866            .args([
8867                "-NoProfile", "-NonInteractive", "-Command",
8868                "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \"  $($_.ObjectClass): $($_.Name)\" }",
8869            ])
8870            .output()
8871            .ok()
8872            .and_then(|o| String::from_utf8(o.stdout).ok())
8873            .unwrap_or_default();
8874
8875        out.push_str("\n=== Administrators Group Members ===\n");
8876        if admins_out.trim().is_empty() {
8877            out.push_str("  (unable to retrieve)\n");
8878        } else {
8879            out.push_str(admins_out.trim());
8880            out.push('\n');
8881        }
8882
8883        let sessions_out = Command::new("powershell")
8884            .args([
8885                "-NoProfile",
8886                "-NonInteractive",
8887                "-Command",
8888                "query user 2>$null",
8889            ])
8890            .output()
8891            .ok()
8892            .and_then(|o| String::from_utf8(o.stdout).ok())
8893            .unwrap_or_default();
8894
8895        out.push_str("\n=== Active Logon Sessions ===\n");
8896        if sessions_out.trim().is_empty() {
8897            out.push_str("  (none or requires elevation)\n");
8898        } else {
8899            for line in sessions_out.lines().take(max_entries) {
8900                if !line.trim().is_empty() {
8901                    out.push_str(&format!("  {}\n", line));
8902                }
8903            }
8904        }
8905
8906        let is_admin = Command::new("powershell")
8907            .args([
8908                "-NoProfile", "-NonInteractive", "-Command",
8909                "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
8910            ])
8911            .output()
8912            .ok()
8913            .and_then(|o| String::from_utf8(o.stdout).ok())
8914            .map(|s| s.trim().to_lowercase())
8915            .unwrap_or_default();
8916
8917        out.push_str("\n=== Current Session Elevation ===\n");
8918        out.push_str(&format!(
8919            "  Running as Administrator: {}\n",
8920            if is_admin.contains("true") {
8921                "YES"
8922            } else {
8923                "no"
8924            }
8925        ));
8926    }
8927
8928    #[cfg(not(target_os = "windows"))]
8929    {
8930        let who_out = Command::new("who")
8931            .output()
8932            .ok()
8933            .and_then(|o| String::from_utf8(o.stdout).ok())
8934            .unwrap_or_default();
8935        out.push_str("=== Active Sessions ===\n");
8936        if who_out.trim().is_empty() {
8937            out.push_str("  (none)\n");
8938        } else {
8939            for line in who_out.lines().take(max_entries) {
8940                out.push_str(&format!("  {}\n", line));
8941            }
8942        }
8943        let id_out = Command::new("id")
8944            .output()
8945            .ok()
8946            .and_then(|o| String::from_utf8(o.stdout).ok())
8947            .unwrap_or_default();
8948        out.push_str(&format!("\n=== Current User ===\n  {}\n", id_out.trim()));
8949    }
8950
8951    Ok(out.trim_end().to_string())
8952}
8953
8954// ── audit_policy ──────────────────────────────────────────────────────────────
8955
8956fn inspect_audit_policy() -> Result<String, String> {
8957    let mut out = String::from("Host inspection: audit_policy\n\n");
8958
8959    #[cfg(target_os = "windows")]
8960    {
8961        let auditpol_out = Command::new("auditpol")
8962            .args(["/get", "/category:*"])
8963            .output()
8964            .ok()
8965            .and_then(|o| String::from_utf8(o.stdout).ok())
8966            .unwrap_or_default();
8967
8968        if auditpol_out.trim().is_empty()
8969            || auditpol_out.to_lowercase().contains("access is denied")
8970        {
8971            out.push_str("Audit policy requires Administrator elevation to read.\n");
8972            out.push_str(
8973                "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
8974            );
8975        } else {
8976            out.push_str("=== Windows Audit Policy ===\n");
8977            let mut any_enabled = false;
8978            for line in auditpol_out.lines() {
8979                let trimmed = line.trim();
8980                if trimmed.is_empty() {
8981                    continue;
8982                }
8983                if trimmed.contains("Success") || trimmed.contains("Failure") {
8984                    out.push_str(&format!("  [ENABLED] {}\n", trimmed));
8985                    any_enabled = true;
8986                } else {
8987                    out.push_str(&format!("  {}\n", trimmed));
8988                }
8989            }
8990            if !any_enabled {
8991                out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
8992                out.push_str(
8993                    "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
8994                );
8995            }
8996        }
8997
8998        let evtlog = Command::new("powershell")
8999            .args([
9000                "-NoProfile", "-NonInteractive", "-Command",
9001                "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
9002            ])
9003            .output()
9004            .ok()
9005            .and_then(|o| String::from_utf8(o.stdout).ok())
9006            .map(|s| s.trim().to_string())
9007            .unwrap_or_default();
9008
9009        out.push_str(&format!(
9010            "\n=== Windows Event Log Service ===\n  Status: {}\n",
9011            if evtlog.is_empty() {
9012                "unknown".to_string()
9013            } else {
9014                evtlog
9015            }
9016        ));
9017    }
9018
9019    #[cfg(not(target_os = "windows"))]
9020    {
9021        let auditd_status = Command::new("systemctl")
9022            .args(["is-active", "auditd"])
9023            .output()
9024            .ok()
9025            .and_then(|o| String::from_utf8(o.stdout).ok())
9026            .map(|s| s.trim().to_string())
9027            .unwrap_or_else(|| "not found".to_string());
9028
9029        out.push_str(&format!(
9030            "=== auditd service ===\n  Status: {}\n",
9031            auditd_status
9032        ));
9033
9034        if auditd_status == "active" {
9035            let rules = Command::new("auditctl")
9036                .args(["-l"])
9037                .output()
9038                .ok()
9039                .and_then(|o| String::from_utf8(o.stdout).ok())
9040                .unwrap_or_default();
9041            out.push_str("\n=== Active Audit Rules ===\n");
9042            if rules.trim().is_empty() || rules.contains("No rules") {
9043                out.push_str("  No rules configured.\n");
9044            } else {
9045                for line in rules.lines() {
9046                    out.push_str(&format!("  {}\n", line));
9047                }
9048            }
9049        }
9050    }
9051
9052    Ok(out.trim_end().to_string())
9053}
9054
9055// ── shares ────────────────────────────────────────────────────────────────────
9056
9057fn inspect_shares(max_entries: usize) -> Result<String, String> {
9058    let mut out = String::from("Host inspection: shares\n\n");
9059
9060    #[cfg(target_os = "windows")]
9061    {
9062        let smb_out = Command::new("powershell")
9063            .args([
9064                "-NoProfile", "-NonInteractive", "-Command",
9065                "Get-SmbShare | ForEach-Object { \"  $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
9066            ])
9067            .output()
9068            .ok()
9069            .and_then(|o| String::from_utf8(o.stdout).ok())
9070            .unwrap_or_default();
9071
9072        out.push_str("=== SMB Shares (exposed by this machine) ===\n");
9073        let smb_lines: Vec<&str> = smb_out
9074            .lines()
9075            .filter(|l| !l.trim().is_empty())
9076            .take(max_entries)
9077            .collect();
9078        if smb_lines.is_empty() {
9079            out.push_str("  No SMB shares or unable to retrieve.\n");
9080        } else {
9081            for line in &smb_lines {
9082                let name = line.trim().split('|').next().unwrap_or("").trim();
9083                if name.ends_with('$') {
9084                    out.push_str(&format!("  {}\n", line.trim()));
9085                } else {
9086                    out.push_str(&format!("  [CUSTOM] {}\n", line.trim()));
9087                }
9088            }
9089        }
9090
9091        let smb_security = Command::new("powershell")
9092            .args([
9093                "-NoProfile", "-NonInteractive", "-Command",
9094                "Get-SmbServerConfiguration | ForEach-Object { \"  SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
9095            ])
9096            .output()
9097            .ok()
9098            .and_then(|o| String::from_utf8(o.stdout).ok())
9099            .unwrap_or_default();
9100
9101        out.push_str("\n=== SMB Server Security Settings ===\n");
9102        if smb_security.trim().is_empty() {
9103            out.push_str("  (unable to retrieve)\n");
9104        } else {
9105            out.push_str(smb_security.trim());
9106            out.push('\n');
9107            if smb_security.to_lowercase().contains("smb1: true") {
9108                out.push_str("  [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
9109            }
9110        }
9111
9112        let drives_out = Command::new("powershell")
9113            .args([
9114                "-NoProfile", "-NonInteractive", "-Command",
9115                "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \"  $($_.Name): -> $($_.DisplayRoot)\" }",
9116            ])
9117            .output()
9118            .ok()
9119            .and_then(|o| String::from_utf8(o.stdout).ok())
9120            .unwrap_or_default();
9121
9122        out.push_str("\n=== Mapped Network Drives ===\n");
9123        if drives_out.trim().is_empty() {
9124            out.push_str("  None.\n");
9125        } else {
9126            for line in drives_out.lines().take(max_entries) {
9127                if !line.trim().is_empty() {
9128                    out.push_str(line);
9129                    out.push('\n');
9130                }
9131            }
9132        }
9133    }
9134
9135    #[cfg(not(target_os = "windows"))]
9136    {
9137        let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
9138        out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
9139        if smb_conf.is_empty() {
9140            out.push_str("  Not found or Samba not installed.\n");
9141        } else {
9142            for line in smb_conf.lines().take(max_entries) {
9143                out.push_str(&format!("  {}\n", line));
9144            }
9145        }
9146        let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
9147        out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
9148        if nfs_exports.is_empty() {
9149            out.push_str("  Not configured.\n");
9150        } else {
9151            for line in nfs_exports.lines().take(max_entries) {
9152                out.push_str(&format!("  {}\n", line));
9153            }
9154        }
9155    }
9156
9157    Ok(out.trim_end().to_string())
9158}
9159
9160// ── dns_servers ───────────────────────────────────────────────────────────────
9161
9162fn inspect_dns_servers() -> Result<String, String> {
9163    let mut out = String::from("Host inspection: dns_servers\n\n");
9164
9165    #[cfg(target_os = "windows")]
9166    {
9167        let dns_out = Command::new("powershell")
9168            .args([
9169                "-NoProfile", "-NonInteractive", "-Command",
9170                "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \"  $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
9171            ])
9172            .output()
9173            .ok()
9174            .and_then(|o| String::from_utf8(o.stdout).ok())
9175            .unwrap_or_default();
9176
9177        out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
9178        if dns_out.trim().is_empty() {
9179            out.push_str("  (unable to retrieve)\n");
9180        } else {
9181            for line in dns_out.lines() {
9182                if line.trim().is_empty() {
9183                    continue;
9184                }
9185                let mut annotation = "";
9186                if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
9187                    annotation = "  <- Google Public DNS";
9188                } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
9189                    annotation = "  <- Cloudflare DNS";
9190                } else if line.contains("9.9.9.9") {
9191                    annotation = "  <- Quad9";
9192                } else if line.contains("208.67.222") || line.contains("208.67.220") {
9193                    annotation = "  <- OpenDNS";
9194                }
9195                out.push_str(line);
9196                out.push_str(annotation);
9197                out.push('\n');
9198            }
9199        }
9200
9201        let doh_out = Command::new("powershell")
9202            .args([
9203                "-NoProfile", "-NonInteractive", "-Command",
9204                "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \"  $($_.ServerAddress): $($_.DohTemplate)\" }",
9205            ])
9206            .output()
9207            .ok()
9208            .and_then(|o| String::from_utf8(o.stdout).ok())
9209            .unwrap_or_default();
9210
9211        out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
9212        if doh_out.trim().is_empty() {
9213            out.push_str("  Not configured (plain DNS).\n");
9214        } else {
9215            out.push_str(doh_out.trim());
9216            out.push('\n');
9217        }
9218
9219        let suffixes = Command::new("powershell")
9220            .args([
9221                "-NoProfile", "-NonInteractive", "-Command",
9222                "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \"  $_\" }",
9223            ])
9224            .output()
9225            .ok()
9226            .and_then(|o| String::from_utf8(o.stdout).ok())
9227            .unwrap_or_default();
9228
9229        if !suffixes.trim().is_empty() {
9230            out.push_str("\n=== DNS Search Suffix List ===\n");
9231            out.push_str(suffixes.trim());
9232            out.push('\n');
9233        }
9234    }
9235
9236    #[cfg(not(target_os = "windows"))]
9237    {
9238        let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
9239        out.push_str("=== /etc/resolv.conf ===\n");
9240        if resolv.is_empty() {
9241            out.push_str("  Not found.\n");
9242        } else {
9243            for line in resolv.lines() {
9244                if !line.trim().is_empty() && !line.starts_with('#') {
9245                    out.push_str(&format!("  {}\n", line));
9246                }
9247            }
9248        }
9249        let resolved_out = Command::new("resolvectl")
9250            .args(["status", "--no-pager"])
9251            .output()
9252            .ok()
9253            .and_then(|o| String::from_utf8(o.stdout).ok())
9254            .unwrap_or_default();
9255        if !resolved_out.is_empty() {
9256            out.push_str("\n=== systemd-resolved ===\n");
9257            for line in resolved_out.lines().take(30) {
9258                out.push_str(&format!("  {}\n", line));
9259            }
9260        }
9261    }
9262
9263    Ok(out.trim_end().to_string())
9264}
9265
9266fn inspect_bitlocker() -> Result<String, String> {
9267    let mut out = String::from("Host inspection: bitlocker\n\n");
9268
9269    #[cfg(target_os = "windows")]
9270    {
9271        let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
9272        let output = Command::new("powershell")
9273            .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
9274            .output()
9275            .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
9276
9277        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9278        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
9279
9280        if !stdout.trim().is_empty() {
9281            out.push_str("=== BitLocker Volumes ===\n");
9282            for line in stdout.lines() {
9283                out.push_str(&format!("  {}\n", line));
9284            }
9285        } else if !stderr.trim().is_empty() {
9286            if stderr.contains("Access is denied") {
9287                out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
9288            } else {
9289                out.push_str(&format!(
9290                    "Error retrieving BitLocker info: {}\n",
9291                    stderr.trim()
9292                ));
9293            }
9294        } else {
9295            out.push_str("No BitLocker volumes detected or access denied.\n");
9296        }
9297    }
9298
9299    #[cfg(not(target_os = "windows"))]
9300    {
9301        out.push_str(
9302            "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
9303        );
9304        let lsblk = Command::new("lsblk")
9305            .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
9306            .output()
9307            .ok()
9308            .and_then(|o| String::from_utf8(o.stdout).ok())
9309            .unwrap_or_default();
9310        if lsblk.contains("crypto_LUKS") {
9311            out.push_str("=== LUKS Encrypted Volumes ===\n");
9312            for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
9313                out.push_str(&format!("  {}\n", line));
9314            }
9315        } else {
9316            out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
9317        }
9318    }
9319
9320    Ok(out.trim_end().to_string())
9321}
9322
9323fn inspect_rdp() -> Result<String, String> {
9324    let mut out = String::from("Host inspection: rdp\n\n");
9325
9326    #[cfg(target_os = "windows")]
9327    {
9328        let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
9329        let f_deny = Command::new("powershell")
9330            .args([
9331                "-NoProfile",
9332                "-Command",
9333                &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
9334            ])
9335            .output()
9336            .ok()
9337            .and_then(|o| String::from_utf8(o.stdout).ok())
9338            .unwrap_or_default()
9339            .trim()
9340            .to_string();
9341
9342        let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
9343        out.push_str(&format!("=== RDP Status: {} ===\n", status));
9344
9345        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"])
9346            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
9347        out.push_str(&format!(
9348            "  Port: {}\n",
9349            if port.is_empty() {
9350                "3389 (default)"
9351            } else {
9352                &port
9353            }
9354        ));
9355
9356        let nla = Command::new("powershell")
9357            .args([
9358                "-NoProfile",
9359                "-Command",
9360                &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
9361            ])
9362            .output()
9363            .ok()
9364            .and_then(|o| String::from_utf8(o.stdout).ok())
9365            .unwrap_or_default()
9366            .trim()
9367            .to_string();
9368        out.push_str(&format!(
9369            "  NLA Required: {}\n",
9370            if nla == "1" { "Yes" } else { "No" }
9371        ));
9372
9373        out.push_str("\n=== Active Sessions ===\n");
9374        let qwinsta = Command::new("qwinsta")
9375            .output()
9376            .ok()
9377            .and_then(|o| String::from_utf8(o.stdout).ok())
9378            .unwrap_or_default();
9379        if qwinsta.trim().is_empty() {
9380            out.push_str("  No active sessions listed.\n");
9381        } else {
9382            for line in qwinsta.lines() {
9383                out.push_str(&format!("  {}\n", line));
9384            }
9385        }
9386
9387        out.push_str("\n=== Firewall Rule Check ===\n");
9388        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))\" }"])
9389            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
9390        if fw.trim().is_empty() {
9391            out.push_str("  No enabled RDP firewall rules found.\n");
9392        } else {
9393            out.push_str(fw.trim_end());
9394            out.push('\n');
9395        }
9396    }
9397
9398    #[cfg(not(target_os = "windows"))]
9399    {
9400        out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
9401        let ss = Command::new("ss")
9402            .args(["-tlnp"])
9403            .output()
9404            .ok()
9405            .and_then(|o| String::from_utf8(o.stdout).ok())
9406            .unwrap_or_default();
9407        let matches: Vec<&str> = ss
9408            .lines()
9409            .filter(|l| l.contains(":3389") || l.contains(":590"))
9410            .collect();
9411        if matches.is_empty() {
9412            out.push_str("  No RDP/VNC listeners detected via 'ss'.\n");
9413        } else {
9414            for m in matches {
9415                out.push_str(&format!("  {}\n", m));
9416            }
9417        }
9418    }
9419
9420    Ok(out.trim_end().to_string())
9421}
9422
9423fn inspect_shadow_copies() -> Result<String, String> {
9424    let mut out = String::from("Host inspection: shadow_copies\n\n");
9425
9426    #[cfg(target_os = "windows")]
9427    {
9428        let output = Command::new("vssadmin")
9429            .args(["list", "shadows"])
9430            .output()
9431            .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
9432        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
9433
9434        if stdout.contains("No items found") || stdout.trim().is_empty() {
9435            out.push_str("No Volume Shadow Copies found.\n");
9436        } else {
9437            out.push_str("=== Volume Shadow Copies ===\n");
9438            for line in stdout.lines().take(50) {
9439                if line.contains("Creation Time:")
9440                    || line.contains("Contents:")
9441                    || line.contains("Volume Name:")
9442                {
9443                    out.push_str(&format!("  {}\n", line.trim()));
9444                }
9445            }
9446        }
9447
9448        out.push_str("\n=== Shadow Copy Storage ===\n");
9449        let storage_out = Command::new("vssadmin")
9450            .args(["list", "shadowstorage"])
9451            .output()
9452            .ok();
9453        if let Some(o) = storage_out {
9454            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9455            for line in stdout.lines() {
9456                if line.contains("Used Shadow Copy Storage space:")
9457                    || line.contains("Max Shadow Copy Storage space:")
9458                {
9459                    out.push_str(&format!("  {}\n", line.trim()));
9460                }
9461            }
9462        }
9463    }
9464
9465    #[cfg(not(target_os = "windows"))]
9466    {
9467        out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
9468        let lvs = Command::new("lvs")
9469            .output()
9470            .ok()
9471            .and_then(|o| String::from_utf8(o.stdout).ok())
9472            .unwrap_or_default();
9473        if !lvs.is_empty() {
9474            out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
9475            out.push_str(&lvs);
9476        } else {
9477            out.push_str("No LVM volumes detected.\n");
9478        }
9479    }
9480
9481    Ok(out.trim_end().to_string())
9482}
9483
9484fn inspect_pagefile() -> Result<String, String> {
9485    let mut out = String::from("Host inspection: pagefile\n\n");
9486
9487    #[cfg(target_os = "windows")]
9488    {
9489        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)\" }";
9490        let output = Command::new("powershell")
9491            .args(["-NoProfile", "-Command", ps_cmd])
9492            .output()
9493            .ok()
9494            .and_then(|o| String::from_utf8(o.stdout).ok())
9495            .unwrap_or_default();
9496
9497        if output.trim().is_empty() {
9498            out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
9499            let managed = Command::new("powershell")
9500                .args([
9501                    "-NoProfile",
9502                    "-Command",
9503                    "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
9504                ])
9505                .output()
9506                .ok()
9507                .and_then(|o| String::from_utf8(o.stdout).ok())
9508                .unwrap_or_default()
9509                .trim()
9510                .to_string();
9511            out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
9512        } else {
9513            out.push_str("=== Page File Usage ===\n");
9514            out.push_str(&output);
9515        }
9516    }
9517
9518    #[cfg(not(target_os = "windows"))]
9519    {
9520        out.push_str("=== Swap Usage (Linux/macOS) ===\n");
9521        let swap = Command::new("swapon")
9522            .args(["--show"])
9523            .output()
9524            .ok()
9525            .and_then(|o| String::from_utf8(o.stdout).ok())
9526            .unwrap_or_default();
9527        if swap.is_empty() {
9528            let free = Command::new("free")
9529                .args(["-h"])
9530                .output()
9531                .ok()
9532                .and_then(|o| String::from_utf8(o.stdout).ok())
9533                .unwrap_or_default();
9534            out.push_str(&free);
9535        } else {
9536            out.push_str(&swap);
9537        }
9538    }
9539
9540    Ok(out.trim_end().to_string())
9541}
9542
9543fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
9544    let mut out = String::from("Host inspection: windows_features\n\n");
9545
9546    #[cfg(target_os = "windows")]
9547    {
9548        out.push_str("=== Quick Check: Notable Features ===\n");
9549        let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
9550        let output = Command::new("powershell")
9551            .args(["-NoProfile", "-Command", quick_ps])
9552            .output()
9553            .ok();
9554
9555        if let Some(o) = output {
9556            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9557            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
9558
9559            if !stdout.trim().is_empty() {
9560                for f in stdout.lines() {
9561                    out.push_str(&format!("  [ENABLED] {}\n", f));
9562                }
9563            } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
9564                out.push_str("  Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
9565            } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
9566                out.push_str(
9567                    "  No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
9568                );
9569            }
9570        }
9571
9572        out.push_str(&format!(
9573            "\n=== All Enabled Features (capped at {}) ===\n",
9574            max_entries
9575        ));
9576        let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
9577        let all_out = Command::new("powershell")
9578            .args(["-NoProfile", "-Command", &all_ps])
9579            .output()
9580            .ok();
9581        if let Some(o) = all_out {
9582            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
9583            if !stdout.trim().is_empty() {
9584                out.push_str(&stdout);
9585            }
9586        }
9587    }
9588
9589    #[cfg(not(target_os = "windows"))]
9590    {
9591        let _ = max_entries;
9592        out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
9593    }
9594
9595    Ok(out.trim_end().to_string())
9596}
9597
9598fn inspect_audio(max_entries: usize) -> Result<String, String> {
9599    let mut out = String::from("Host inspection: audio\n\n");
9600
9601    #[cfg(target_os = "windows")]
9602    {
9603        let n = max_entries.clamp(5, 20);
9604        let services = collect_services().unwrap_or_default();
9605        let core_service_names = ["Audiosrv", "AudioEndpointBuilder"];
9606        let bluetooth_audio_service_names = ["BthAvctpSvc", "BTAGService"];
9607
9608        let core_services: Vec<&ServiceEntry> = services
9609            .iter()
9610            .filter(|entry| {
9611                core_service_names
9612                    .iter()
9613                    .any(|name| entry.name.eq_ignore_ascii_case(name))
9614            })
9615            .collect();
9616        let bluetooth_audio_services: Vec<&ServiceEntry> = services
9617            .iter()
9618            .filter(|entry| {
9619                bluetooth_audio_service_names
9620                    .iter()
9621                    .any(|name| entry.name.eq_ignore_ascii_case(name))
9622            })
9623            .collect();
9624
9625        let probe_script = r#"
9626$media = @(Get-PnpDevice -Class Media -ErrorAction SilentlyContinue |
9627    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9628$endpoints = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
9629    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9630$sound = @(Get-CimInstance Win32_SoundDevice -ErrorAction SilentlyContinue |
9631    Select-Object Name, Status, Manufacturer, PNPDeviceID)
9632[pscustomobject]@{
9633    Media = $media
9634    Endpoints = $endpoints
9635    SoundDevices = $sound
9636} | ConvertTo-Json -Compress -Depth 4
9637"#;
9638        let probe_raw = Command::new("powershell")
9639            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
9640            .output()
9641            .ok()
9642            .and_then(|o| String::from_utf8(o.stdout).ok())
9643            .unwrap_or_default();
9644        let probe_loaded = !probe_raw.trim().is_empty();
9645        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
9646
9647        let endpoints = parse_windows_pnp_devices(probe_value.get("Endpoints"));
9648        let media_devices = parse_windows_pnp_devices(probe_value.get("Media"));
9649        let sound_devices = parse_windows_sound_devices(probe_value.get("SoundDevices"));
9650
9651        let playback_endpoints: Vec<&WindowsPnpDevice> = endpoints
9652            .iter()
9653            .filter(|device| !is_microphone_like_name(&device.name))
9654            .collect();
9655        let recording_endpoints: Vec<&WindowsPnpDevice> = endpoints
9656            .iter()
9657            .filter(|device| is_microphone_like_name(&device.name))
9658            .collect();
9659        let bluetooth_endpoints: Vec<&WindowsPnpDevice> = endpoints
9660            .iter()
9661            .filter(|device| is_bluetooth_like_name(&device.name))
9662            .collect();
9663        let endpoint_problems: Vec<&WindowsPnpDevice> = endpoints
9664            .iter()
9665            .filter(|device| windows_device_has_issue(device))
9666            .collect();
9667        let media_problems: Vec<&WindowsPnpDevice> = media_devices
9668            .iter()
9669            .filter(|device| windows_device_has_issue(device))
9670            .collect();
9671        let sound_problems: Vec<&WindowsSoundDevice> = sound_devices
9672            .iter()
9673            .filter(|device| windows_sound_device_has_issue(device))
9674            .collect();
9675
9676        let mut findings = Vec::new();
9677
9678        let stopped_core_services: Vec<&ServiceEntry> = core_services
9679            .iter()
9680            .copied()
9681            .filter(|service| !service_is_running(service))
9682            .collect();
9683        if !stopped_core_services.is_empty() {
9684            let names = stopped_core_services
9685                .iter()
9686                .map(|service| service.name.as_str())
9687                .collect::<Vec<_>>()
9688                .join(", ");
9689            findings.push(AuditFinding {
9690                finding: format!("Core audio services are not running: {names}"),
9691                impact: "Playback and recording devices can vanish or fail even when the hardware is physically present.".to_string(),
9692                fix: "Start Windows Audio (`Audiosrv`) and Windows Audio Endpoint Builder, then recheck the endpoint inventory before reinstalling drivers.".to_string(),
9693            });
9694        }
9695
9696        if probe_loaded
9697            && endpoints.is_empty()
9698            && media_devices.is_empty()
9699            && sound_devices.is_empty()
9700        {
9701            findings.push(AuditFinding {
9702                finding: "No audio endpoints or sound hardware were detected in the Windows device inventory.".to_string(),
9703                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(),
9704                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(),
9705            });
9706        }
9707
9708        if !endpoint_problems.is_empty() || !media_problems.is_empty() || !sound_problems.is_empty()
9709        {
9710            let mut problem_labels = Vec::new();
9711            problem_labels.extend(
9712                endpoint_problems
9713                    .iter()
9714                    .take(3)
9715                    .map(|device| device.name.clone()),
9716            );
9717            problem_labels.extend(
9718                media_problems
9719                    .iter()
9720                    .take(3)
9721                    .map(|device| device.name.clone()),
9722            );
9723            problem_labels.extend(
9724                sound_problems
9725                    .iter()
9726                    .take(3)
9727                    .map(|device| device.name.clone()),
9728            );
9729            findings.push(AuditFinding {
9730                finding: format!(
9731                    "Windows reports audio device issues for: {}",
9732                    problem_labels.join(", ")
9733                ),
9734                impact: "Apps can lose speakers, microphones, or headset paths when endpoint or media-class devices are degraded, disabled, or driver-broken.".to_string(),
9735                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(),
9736            });
9737        }
9738
9739        let stopped_bt_audio_services: Vec<&ServiceEntry> = bluetooth_audio_services
9740            .iter()
9741            .copied()
9742            .filter(|service| !service_is_running(service))
9743            .collect();
9744        if !bluetooth_endpoints.is_empty() && !stopped_bt_audio_services.is_empty() {
9745            let names = stopped_bt_audio_services
9746                .iter()
9747                .map(|service| service.name.as_str())
9748                .collect::<Vec<_>>()
9749                .join(", ");
9750            findings.push(AuditFinding {
9751                finding: format!(
9752                    "Bluetooth-branded audio endpoints exist, but Bluetooth audio services are not fully running: {names}"
9753                ),
9754                impact: "Headsets may pair yet fail to expose the correct playback or microphone profile, especially after wake or reconnect events.".to_string(),
9755                fix: "Restart the Bluetooth audio services and reconnect the headset before blaming the application layer.".to_string(),
9756            });
9757        }
9758
9759        out.push_str("=== Findings ===\n");
9760        if findings.is_empty() {
9761            out.push_str("- Finding: No obvious Windows audio-service outage or device-inventory failure was detected.\n");
9762            out.push_str("  Impact: Playback and recording look structurally present from this inspection pass.\n");
9763            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");
9764        } else {
9765            for finding in &findings {
9766                out.push_str(&format!("- Finding: {}\n", finding.finding));
9767                out.push_str(&format!("  Impact: {}\n", finding.impact));
9768                out.push_str(&format!("  Fix: {}\n", finding.fix));
9769            }
9770        }
9771
9772        out.push_str("\n=== Audio services ===\n");
9773        if core_services.is_empty() && bluetooth_audio_services.is_empty() {
9774            out.push_str(
9775                "- No Windows audio services were retrieved from the service inventory.\n",
9776            );
9777        } else {
9778            for service in core_services.iter().chain(bluetooth_audio_services.iter()) {
9779                out.push_str(&format!(
9780                    "- {} | Status: {} | Startup: {}\n",
9781                    service.name,
9782                    service.status,
9783                    service.startup.as_deref().unwrap_or("Unknown")
9784                ));
9785            }
9786        }
9787
9788        out.push_str("\n=== Playback and recording endpoints ===\n");
9789        if !probe_loaded {
9790            out.push_str("- Windows endpoint inventory probe returned no data.\n");
9791        } else if endpoints.is_empty() {
9792            out.push_str("- No audio endpoints detected.\n");
9793        } else {
9794            out.push_str(&format!(
9795                "- Playback-style endpoints: {} | Recording-style endpoints: {}\n",
9796                playback_endpoints.len(),
9797                recording_endpoints.len()
9798            ));
9799            for device in playback_endpoints.iter().take(n) {
9800                out.push_str(&format!(
9801                    "- [PLAYBACK] {} | Status: {}{}\n",
9802                    device.name,
9803                    device.status,
9804                    device
9805                        .problem
9806                        .filter(|problem| *problem != 0)
9807                        .map(|problem| format!(" | ProblemCode: {problem}"))
9808                        .unwrap_or_default()
9809                ));
9810            }
9811            for device in recording_endpoints.iter().take(n) {
9812                out.push_str(&format!(
9813                    "- [MIC] {} | Status: {}{}\n",
9814                    device.name,
9815                    device.status,
9816                    device
9817                        .problem
9818                        .filter(|problem| *problem != 0)
9819                        .map(|problem| format!(" | ProblemCode: {problem}"))
9820                        .unwrap_or_default()
9821                ));
9822            }
9823        }
9824
9825        out.push_str("\n=== Sound hardware devices ===\n");
9826        if sound_devices.is_empty() {
9827            out.push_str("- No Win32_SoundDevice entries were returned.\n");
9828        } else {
9829            for device in sound_devices.iter().take(n) {
9830                out.push_str(&format!(
9831                    "- {} | Status: {}{}\n",
9832                    device.name,
9833                    device.status,
9834                    device
9835                        .manufacturer
9836                        .as_deref()
9837                        .map(|manufacturer| format!(" | Vendor: {manufacturer}"))
9838                        .unwrap_or_default()
9839                ));
9840            }
9841        }
9842
9843        out.push_str("\n=== Media-class device inventory ===\n");
9844        if media_devices.is_empty() {
9845            out.push_str("- No media-class PnP devices were returned.\n");
9846        } else {
9847            for device in media_devices.iter().take(n) {
9848                out.push_str(&format!(
9849                    "- {} | Status: {}{}\n",
9850                    device.name,
9851                    device.status,
9852                    device
9853                        .class_name
9854                        .as_deref()
9855                        .map(|class_name| format!(" | Class: {class_name}"))
9856                        .unwrap_or_default()
9857                ));
9858            }
9859        }
9860    }
9861
9862    #[cfg(not(target_os = "windows"))]
9863    {
9864        let _ = max_entries;
9865        out.push_str(
9866            "Audio inspection currently provides deep endpoint and service coverage on Windows.\n",
9867        );
9868        out.push_str(
9869            "On Linux/macOS, ask narrower questions about PipeWire/PulseAudio/ALSA state if you want a dedicated native audit path added.\n",
9870        );
9871    }
9872
9873    Ok(out.trim_end().to_string())
9874}
9875
9876fn inspect_bluetooth(max_entries: usize) -> Result<String, String> {
9877    let mut out = String::from("Host inspection: bluetooth\n\n");
9878
9879    #[cfg(target_os = "windows")]
9880    {
9881        let n = max_entries.clamp(5, 20);
9882        let services = collect_services().unwrap_or_default();
9883        let bluetooth_services: Vec<&ServiceEntry> = services
9884            .iter()
9885            .filter(|entry| {
9886                entry.name.eq_ignore_ascii_case("bthserv")
9887                    || entry.name.eq_ignore_ascii_case("BthAvctpSvc")
9888                    || entry.name.eq_ignore_ascii_case("BTAGService")
9889                    || entry.name.starts_with("BluetoothUserService")
9890                    || entry
9891                        .display_name
9892                        .as_deref()
9893                        .unwrap_or("")
9894                        .to_ascii_lowercase()
9895                        .contains("bluetooth")
9896            })
9897            .collect();
9898
9899        let probe_script = r#"
9900$radios = @(Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue |
9901    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9902$devices = @(Get-PnpDevice -ErrorAction SilentlyContinue |
9903    Where-Object {
9904        $_.Class -eq 'Bluetooth' -or
9905        $_.FriendlyName -match 'Bluetooth' -or
9906        $_.InstanceId -like 'BTH*'
9907    } |
9908    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9909$audio = @(Get-PnpDevice -Class AudioEndpoint -ErrorAction SilentlyContinue |
9910    Where-Object { $_.FriendlyName -match 'Bluetooth|Hands-Free|A2DP' } |
9911    Select-Object FriendlyName, Status, Problem, Class, InstanceId)
9912[pscustomobject]@{
9913    Radios = $radios
9914    Devices = $devices
9915    AudioEndpoints = $audio
9916} | ConvertTo-Json -Compress -Depth 4
9917"#;
9918        let probe_raw = Command::new("powershell")
9919            .args(["-NoProfile", "-NonInteractive", "-Command", probe_script])
9920            .output()
9921            .ok()
9922            .and_then(|o| String::from_utf8(o.stdout).ok())
9923            .unwrap_or_default();
9924        let probe_loaded = !probe_raw.trim().is_empty();
9925        let probe_value = serde_json::from_str::<Value>(probe_raw.trim()).unwrap_or(Value::Null);
9926
9927        let radios = parse_windows_pnp_devices(probe_value.get("Radios"));
9928        let devices = parse_windows_pnp_devices(probe_value.get("Devices"));
9929        let audio_endpoints = parse_windows_pnp_devices(probe_value.get("AudioEndpoints"));
9930        let radio_problems: Vec<&WindowsPnpDevice> = radios
9931            .iter()
9932            .filter(|device| windows_device_has_issue(device))
9933            .collect();
9934        let device_problems: Vec<&WindowsPnpDevice> = devices
9935            .iter()
9936            .filter(|device| windows_device_has_issue(device))
9937            .collect();
9938
9939        let mut findings = Vec::new();
9940
9941        if probe_loaded && radios.is_empty() {
9942            findings.push(AuditFinding {
9943                finding: "No Bluetooth radio or adapter was detected in the device inventory.".to_string(),
9944                impact: "Pairing, reconnects, and Bluetooth audio paths cannot work without a healthy local radio.".to_string(),
9945                fix: "Check whether Bluetooth is disabled in firmware, turned off in Windows, or missing its vendor driver.".to_string(),
9946            });
9947        }
9948
9949        let stopped_bluetooth_services: Vec<&ServiceEntry> = bluetooth_services
9950            .iter()
9951            .copied()
9952            .filter(|service| !service_is_running(service))
9953            .collect();
9954        if !stopped_bluetooth_services.is_empty() {
9955            let names = stopped_bluetooth_services
9956                .iter()
9957                .map(|service| service.name.as_str())
9958                .collect::<Vec<_>>()
9959                .join(", ");
9960            findings.push(AuditFinding {
9961                finding: format!("Bluetooth-related services are not fully running: {names}"),
9962                impact: "Discovery, pairing, reconnects, and headset profile switching can all fail even when the adapter appears installed.".to_string(),
9963                fix: "Start the Bluetooth Support Service first, then reconnect the device and recheck the adapter and endpoint state.".to_string(),
9964            });
9965        }
9966
9967        if !radio_problems.is_empty() || !device_problems.is_empty() {
9968            let problem_labels = radio_problems
9969                .iter()
9970                .chain(device_problems.iter())
9971                .take(5)
9972                .map(|device| device.name.as_str())
9973                .collect::<Vec<_>>()
9974                .join(", ");
9975            findings.push(AuditFinding {
9976                finding: format!("Windows reports Bluetooth device issues for: {problem_labels}"),
9977                impact: "A degraded radio or paired-device node can cause pairing loops, sudden disconnects, or one-way headset behavior.".to_string(),
9978                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(),
9979            });
9980        }
9981
9982        if !audio_endpoints.is_empty()
9983            && bluetooth_services
9984                .iter()
9985                .any(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
9986            && bluetooth_services
9987                .iter()
9988                .filter(|service| service.name.eq_ignore_ascii_case("BthAvctpSvc"))
9989                .any(|service| !service_is_running(service))
9990        {
9991            findings.push(AuditFinding {
9992                finding: "Bluetooth audio endpoints exist, but the Bluetooth AVCTP service is not running.".to_string(),
9993                impact: "Headsets can connect yet expose the wrong audio role or lose media controls and microphone availability.".to_string(),
9994                fix: "Restart the AVCTP service and reconnect the headset before troubleshooting the app or conferencing tool.".to_string(),
9995            });
9996        }
9997
9998        out.push_str("=== Findings ===\n");
9999        if findings.is_empty() {
10000            out.push_str("- Finding: No obvious Bluetooth radio, service, or paired-device failure was detected.\n");
10001            out.push_str("  Impact: The Bluetooth stack looks structurally healthy from this inspection pass.\n");
10002            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");
10003        } else {
10004            for finding in &findings {
10005                out.push_str(&format!("- Finding: {}\n", finding.finding));
10006                out.push_str(&format!("  Impact: {}\n", finding.impact));
10007                out.push_str(&format!("  Fix: {}\n", finding.fix));
10008            }
10009        }
10010
10011        out.push_str("\n=== Bluetooth services ===\n");
10012        if bluetooth_services.is_empty() {
10013            out.push_str(
10014                "- No Bluetooth-related services were retrieved from the service inventory.\n",
10015            );
10016        } else {
10017            for service in bluetooth_services.iter().take(n) {
10018                out.push_str(&format!(
10019                    "- {} | Status: {} | Startup: {}\n",
10020                    service.name,
10021                    service.status,
10022                    service.startup.as_deref().unwrap_or("Unknown")
10023                ));
10024            }
10025        }
10026
10027        out.push_str("\n=== Bluetooth radios and adapters ===\n");
10028        if !probe_loaded {
10029            out.push_str("- Windows Bluetooth adapter inventory probe returned no data.\n");
10030        } else if radios.is_empty() {
10031            out.push_str("- No Bluetooth radios detected.\n");
10032        } else {
10033            for device in radios.iter().take(n) {
10034                out.push_str(&format!(
10035                    "- {} | Status: {}{}\n",
10036                    device.name,
10037                    device.status,
10038                    device
10039                        .problem
10040                        .filter(|problem| *problem != 0)
10041                        .map(|problem| format!(" | ProblemCode: {problem}"))
10042                        .unwrap_or_default()
10043                ));
10044            }
10045        }
10046
10047        out.push_str("\n=== Bluetooth-associated devices ===\n");
10048        if devices.is_empty() {
10049            out.push_str("- No Bluetooth-associated device nodes detected.\n");
10050        } else {
10051            for device in devices.iter().take(n) {
10052                out.push_str(&format!(
10053                    "- {} | Status: {}{}\n",
10054                    device.name,
10055                    device.status,
10056                    device
10057                        .class_name
10058                        .as_deref()
10059                        .map(|class_name| format!(" | Class: {class_name}"))
10060                        .unwrap_or_default()
10061                ));
10062            }
10063        }
10064
10065        out.push_str("\n=== Bluetooth audio endpoints ===\n");
10066        if audio_endpoints.is_empty() {
10067            out.push_str("- No Bluetooth-branded audio endpoints detected.\n");
10068        } else {
10069            for device in audio_endpoints.iter().take(n) {
10070                out.push_str(&format!(
10071                    "- {} | Status: {}{}\n",
10072                    device.name,
10073                    device.status,
10074                    device
10075                        .instance_id
10076                        .as_deref()
10077                        .map(|instance_id| format!(" | Instance: {instance_id}"))
10078                        .unwrap_or_default()
10079                ));
10080            }
10081        }
10082    }
10083
10084    #[cfg(not(target_os = "windows"))]
10085    {
10086        let _ = max_entries;
10087        out.push_str("Bluetooth inspection currently provides deep service and device coverage on Windows.\n");
10088        out.push_str(
10089            "On Linux/macOS, ask a narrower Bluetooth question if you want a dedicated native audit path added.\n",
10090        );
10091    }
10092
10093    Ok(out.trim_end().to_string())
10094}
10095
10096fn inspect_printers(max_entries: usize) -> Result<String, String> {
10097    let mut out = String::from("Host inspection: printers\n\n");
10098
10099    #[cfg(target_os = "windows")]
10100    {
10101        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)])
10102            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10103        if list.trim().is_empty() {
10104            out.push_str("No printers detected.\n");
10105        } else {
10106            out.push_str("=== Installed Printers ===\n");
10107            out.push_str(&list);
10108        }
10109
10110        let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \"  [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
10111            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10112        if !jobs.trim().is_empty() {
10113            out.push_str("\n=== Active Print Jobs ===\n");
10114            out.push_str(&jobs);
10115        }
10116    }
10117
10118    #[cfg(not(target_os = "windows"))]
10119    {
10120        let _ = max_entries;
10121        out.push_str("Checking LPSTAT for printers...\n");
10122        let lpstat = Command::new("lpstat")
10123            .args(["-p", "-d"])
10124            .output()
10125            .ok()
10126            .and_then(|o| String::from_utf8(o.stdout).ok())
10127            .unwrap_or_default();
10128        if lpstat.is_empty() {
10129            out.push_str("  No CUPS/LP printers found.\n");
10130        } else {
10131            out.push_str(&lpstat);
10132        }
10133    }
10134
10135    Ok(out.trim_end().to_string())
10136}
10137
10138fn inspect_winrm() -> Result<String, String> {
10139    let mut out = String::from("Host inspection: winrm\n\n");
10140
10141    #[cfg(target_os = "windows")]
10142    {
10143        let svc = Command::new("powershell")
10144            .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
10145            .output()
10146            .ok()
10147            .and_then(|o| String::from_utf8(o.stdout).ok())
10148            .unwrap_or_default()
10149            .trim()
10150            .to_string();
10151        out.push_str(&format!(
10152            "WinRM Service Status: {}\n\n",
10153            if svc.is_empty() { "NOT_FOUND" } else { &svc }
10154        ));
10155
10156        out.push_str("=== WinRM Listeners ===\n");
10157        let output = Command::new("powershell")
10158            .args([
10159                "-NoProfile",
10160                "-Command",
10161                "winrm enumerate winrm/config/listener 2>$null",
10162            ])
10163            .output()
10164            .ok();
10165        if let Some(o) = output {
10166            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10167            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10168
10169            if !stdout.trim().is_empty() {
10170                for line in stdout.lines() {
10171                    if line.contains("Address =")
10172                        || line.contains("Transport =")
10173                        || line.contains("Port =")
10174                    {
10175                        out.push_str(&format!("  {}\n", line.trim()));
10176                    }
10177                }
10178            } else if stderr.contains("Access is denied") {
10179                out.push_str("  Error: Access denied to WinRM configuration.\n");
10180            } else {
10181                out.push_str("  No listeners configured.\n");
10182            }
10183        }
10184
10185        out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
10186        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))\" }"])
10187            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10188        if test_out.trim().is_empty() {
10189            out.push_str("  WinRM not responding to local WS-Man requests.\n");
10190        } else {
10191            out.push_str(&test_out);
10192        }
10193    }
10194
10195    #[cfg(not(target_os = "windows"))]
10196    {
10197        out.push_str(
10198            "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
10199        );
10200        let ss = Command::new("ss")
10201            .args(["-tln"])
10202            .output()
10203            .ok()
10204            .and_then(|o| String::from_utf8(o.stdout).ok())
10205            .unwrap_or_default();
10206        if ss.contains(":5985") || ss.contains(":5986") {
10207            out.push_str("  WinRM ports (5985/5986) are listening.\n");
10208        } else {
10209            out.push_str("  WinRM ports not detected.\n");
10210        }
10211    }
10212
10213    Ok(out.trim_end().to_string())
10214}
10215
10216fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
10217    let mut out = String::from("Host inspection: network_stats\n\n");
10218
10219    #[cfg(target_os = "windows")]
10220    {
10221        let ps_cmd = format!(
10222            "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
10223             Start-Sleep -Milliseconds 250; \
10224             $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
10225             $s2 | ForEach-Object {{ \
10226                $name = $_.Name; \
10227                $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
10228                if ($prev) {{ \
10229                    $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
10230                    $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
10231                    $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
10232                    $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
10233                    $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
10234                    $tt = [math]::Round($_.SentBytes / 1MB, 2); \
10235                    \"  $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
10236                }} \
10237             }}",
10238            max_entries
10239        );
10240        let output = Command::new("powershell")
10241            .args(["-NoProfile", "-Command", &ps_cmd])
10242            .output()
10243            .ok()
10244            .and_then(|o| String::from_utf8(o.stdout).ok())
10245            .unwrap_or_default();
10246        if output.trim().is_empty() {
10247            out.push_str("No network adapter statistics available.\n");
10248        } else {
10249            out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
10250            out.push_str(&output);
10251        }
10252
10253        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)\" } }"])
10254            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10255        if !discards.trim().is_empty() {
10256            out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
10257            out.push_str(&discards);
10258        }
10259    }
10260
10261    #[cfg(not(target_os = "windows"))]
10262    {
10263        let _ = max_entries;
10264        out.push_str("=== Network Stats (ip -s link) ===\n");
10265        let ip_s = Command::new("ip")
10266            .args(["-s", "link"])
10267            .output()
10268            .ok()
10269            .and_then(|o| String::from_utf8(o.stdout).ok())
10270            .unwrap_or_default();
10271        if ip_s.is_empty() {
10272            let netstat = Command::new("netstat")
10273                .args(["-i"])
10274                .output()
10275                .ok()
10276                .and_then(|o| String::from_utf8(o.stdout).ok())
10277                .unwrap_or_default();
10278            out.push_str(&netstat);
10279        } else {
10280            out.push_str(&ip_s);
10281        }
10282    }
10283
10284    Ok(out.trim_end().to_string())
10285}
10286
10287fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
10288    let mut out = String::from("Host inspection: udp_ports\n\n");
10289
10290    #[cfg(target_os = "windows")]
10291    {
10292        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);
10293        let output = Command::new("powershell")
10294            .args(["-NoProfile", "-Command", &ps_cmd])
10295            .output()
10296            .ok();
10297
10298        if let Some(o) = output {
10299            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10300            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10301
10302            if !stdout.trim().is_empty() {
10303                out.push_str("=== UDP Listeners (Local:Port) ===\n");
10304                for line in stdout.lines() {
10305                    let mut note = "";
10306                    if line.contains(":53 ") {
10307                        note = " [DNS]";
10308                    } else if line.contains(":67 ") || line.contains(":68 ") {
10309                        note = " [DHCP]";
10310                    } else if line.contains(":123 ") {
10311                        note = " [NTP]";
10312                    } else if line.contains(":161 ") {
10313                        note = " [SNMP]";
10314                    } else if line.contains(":1900 ") {
10315                        note = " [SSDP/UPnP]";
10316                    } else if line.contains(":5353 ") {
10317                        note = " [mDNS]";
10318                    }
10319
10320                    out.push_str(&format!("{}{}\n", line, note));
10321                }
10322            } else if stderr.contains("Access is denied") {
10323                out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
10324            } else {
10325                out.push_str("No UDP listeners detected.\n");
10326            }
10327        }
10328    }
10329
10330    #[cfg(not(target_os = "windows"))]
10331    {
10332        let ss_out = Command::new("ss")
10333            .args(["-ulnp"])
10334            .output()
10335            .ok()
10336            .and_then(|o| String::from_utf8(o.stdout).ok())
10337            .unwrap_or_default();
10338        out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
10339        if ss_out.is_empty() {
10340            let netstat_out = Command::new("netstat")
10341                .args(["-ulnp"])
10342                .output()
10343                .ok()
10344                .and_then(|o| String::from_utf8(o.stdout).ok())
10345                .unwrap_or_default();
10346            if netstat_out.is_empty() {
10347                out.push_str("  Neither 'ss' nor 'netstat' available.\n");
10348            } else {
10349                for line in netstat_out.lines().take(max_entries) {
10350                    out.push_str(&format!("  {}\n", line));
10351                }
10352            }
10353        } else {
10354            for line in ss_out.lines().take(max_entries) {
10355                out.push_str(&format!("  {}\n", line));
10356            }
10357        }
10358    }
10359
10360    Ok(out.trim_end().to_string())
10361}
10362
10363fn inspect_gpo() -> Result<String, String> {
10364    let mut out = String::from("Host inspection: gpo\n\n");
10365
10366    #[cfg(target_os = "windows")]
10367    {
10368        let output = Command::new("gpresult")
10369            .args(["/r", "/scope", "computer"])
10370            .output()
10371            .ok();
10372
10373        if let Some(o) = output {
10374            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10375            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
10376
10377            if stdout.contains("Applied Group Policy Objects") {
10378                out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
10379                let mut capture = false;
10380                for line in stdout.lines() {
10381                    if line.contains("Applied Group Policy Objects") {
10382                        capture = true;
10383                    } else if capture && line.contains("The following GPOs were not applied") {
10384                        break;
10385                    }
10386                    if capture && !line.trim().is_empty() {
10387                        out.push_str(&format!("  {}\n", line.trim()));
10388                    }
10389                }
10390            } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
10391                out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
10392            } else {
10393                out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
10394            }
10395        }
10396    }
10397
10398    #[cfg(not(target_os = "windows"))]
10399    {
10400        out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
10401    }
10402
10403    Ok(out.trim_end().to_string())
10404}
10405
10406fn inspect_certificates(max_entries: usize) -> Result<String, String> {
10407    let mut out = String::from("Host inspection: certificates\n\n");
10408
10409    #[cfg(target_os = "windows")]
10410    {
10411        let ps_cmd = format!(
10412            "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
10413                $days = ($_.NotAfter - (Get-Date)).Days; \
10414                $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
10415                \"  $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
10416            }}", 
10417            max_entries
10418        );
10419        let output = Command::new("powershell")
10420            .args(["-NoProfile", "-Command", &ps_cmd])
10421            .output()
10422            .ok();
10423
10424        if let Some(o) = output {
10425            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10426            if !stdout.trim().is_empty() {
10427                out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
10428                out.push_str(&stdout);
10429            } else {
10430                out.push_str("No certificates found in the Local Machine Personal store.\n");
10431            }
10432        }
10433    }
10434
10435    #[cfg(not(target_os = "windows"))]
10436    {
10437        let _ = max_entries;
10438        out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
10439        // Check standard cert locations
10440        for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
10441            if Path::new(path).exists() {
10442                out.push_str(&format!("  Cert directory found: {}\n", path));
10443            }
10444        }
10445    }
10446
10447    Ok(out.trim_end().to_string())
10448}
10449
10450fn inspect_integrity() -> Result<String, String> {
10451    let mut out = String::from("Host inspection: integrity\n\n");
10452
10453    #[cfg(target_os = "windows")]
10454    {
10455        let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
10456        let output = Command::new("powershell")
10457            .args(["-NoProfile", "-Command", &ps_cmd])
10458            .output()
10459            .ok();
10460
10461        if let Some(o) = output {
10462            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10463            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10464                out.push_str("=== Windows Component Store Health (CBS) ===\n");
10465                let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
10466                let repair = val
10467                    .get("AutoRepairNeeded")
10468                    .and_then(|v| v.as_u64())
10469                    .unwrap_or(0);
10470
10471                out.push_str(&format!(
10472                    "  Corruption Detected: {}\n",
10473                    if corrupt != 0 {
10474                        "YES (SFC/DISM recommended)"
10475                    } else {
10476                        "No"
10477                    }
10478                ));
10479                out.push_str(&format!(
10480                    "  Auto-Repair Needed: {}\n",
10481                    if repair != 0 { "YES" } else { "No" }
10482                ));
10483
10484                if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
10485                    out.push_str(&format!("  Last Repair Attempt: (Raw code: {})\n", last));
10486                }
10487            } else {
10488                out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
10489            }
10490        }
10491
10492        if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
10493            out.push_str(
10494                "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
10495            );
10496        }
10497    }
10498
10499    #[cfg(not(target_os = "windows"))]
10500    {
10501        out.push_str("System integrity check (Linux)\n\n");
10502        let pkg_check = Command::new("rpm")
10503            .args(["-Va"])
10504            .output()
10505            .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
10506            .ok();
10507        if let Some(o) = pkg_check {
10508            out.push_str("  Package verification system active.\n");
10509            if o.status.success() {
10510                out.push_str("  No major package integrity issues detected.\n");
10511            }
10512        }
10513    }
10514
10515    Ok(out.trim_end().to_string())
10516}
10517
10518fn inspect_domain() -> Result<String, String> {
10519    let mut out = String::from("Host inspection: domain\n\n");
10520
10521    #[cfg(target_os = "windows")]
10522    {
10523        out.push_str("=== Windows Domain / Workgroup Identity ===\n");
10524        let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
10525        let output = Command::new("powershell")
10526            .args(["-NoProfile", "-Command", &ps_cmd])
10527            .output()
10528            .ok();
10529
10530        if let Some(o) = output {
10531            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
10532            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
10533                let part_of_domain = val
10534                    .get("PartOfDomain")
10535                    .and_then(|v| v.as_bool())
10536                    .unwrap_or(false);
10537                let domain = val
10538                    .get("Domain")
10539                    .and_then(|v| v.as_str())
10540                    .unwrap_or("Unknown");
10541                let workgroup = val
10542                    .get("Workgroup")
10543                    .and_then(|v| v.as_str())
10544                    .unwrap_or("Unknown");
10545
10546                out.push_str(&format!(
10547                    "  Join Status: {}\n",
10548                    if part_of_domain {
10549                        "DOMAIN JOINED"
10550                    } else {
10551                        "WORKGROUP"
10552                    }
10553                ));
10554                if part_of_domain {
10555                    out.push_str(&format!("  Active Directory Domain: {}\n", domain));
10556                } else {
10557                    out.push_str(&format!("  Workgroup Name: {}\n", workgroup));
10558                }
10559
10560                if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
10561                    out.push_str(&format!("  NetBIOS Name: {}\n", name));
10562                }
10563            } else {
10564                out.push_str("  Domain identity data unavailable from WMI.\n");
10565            }
10566        } else {
10567            out.push_str("  Domain identity data unavailable from WMI.\n");
10568        }
10569    }
10570
10571    #[cfg(not(target_os = "windows"))]
10572    {
10573        let domainname = Command::new("domainname")
10574            .output()
10575            .ok()
10576            .and_then(|o| String::from_utf8(o.stdout).ok())
10577            .unwrap_or_default();
10578        out.push_str("=== Linux Domain Identity ===\n");
10579        if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
10580            out.push_str(&format!("  NIS/YP Domain: {}\n", domainname.trim()));
10581        } else {
10582            out.push_str("  No NIS domain configured.\n");
10583        }
10584    }
10585
10586    Ok(out.trim_end().to_string())
10587}
10588
10589fn inspect_device_health() -> Result<String, String> {
10590    let mut out = String::from("Host inspection: device_health\n\n");
10591
10592    #[cfg(target_os = "windows")]
10593    {
10594        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)\" }";
10595        let output = Command::new("powershell")
10596            .args(["-NoProfile", "-Command", ps_cmd])
10597            .output()
10598            .ok()
10599            .and_then(|o| String::from_utf8(o.stdout).ok())
10600            .unwrap_or_default();
10601
10602        if output.trim().is_empty() {
10603            out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
10604        } else {
10605            out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
10606            out.push_str(&output);
10607            out.push_str(
10608                "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
10609            );
10610        }
10611    }
10612
10613    #[cfg(not(target_os = "windows"))]
10614    {
10615        out.push_str("Checking dmesg for hardware errors...\n");
10616        let dmesg = Command::new("dmesg")
10617            .args(["--level=err,crit,alert"])
10618            .output()
10619            .ok()
10620            .and_then(|o| String::from_utf8(o.stdout).ok())
10621            .unwrap_or_default();
10622        if dmesg.is_empty() {
10623            out.push_str("  No critical hardware errors found in dmesg.\n");
10624        } else {
10625            out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
10626        }
10627    }
10628
10629    Ok(out.trim_end().to_string())
10630}
10631
10632fn inspect_drivers(max_entries: usize) -> Result<String, String> {
10633    let mut out = String::from("Host inspection: drivers\n\n");
10634
10635    #[cfg(target_os = "windows")]
10636    {
10637        out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
10638        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);
10639        let output = Command::new("powershell")
10640            .args(["-NoProfile", "-Command", &ps_cmd])
10641            .output()
10642            .ok()
10643            .and_then(|o| String::from_utf8(o.stdout).ok())
10644            .unwrap_or_default();
10645
10646        if output.trim().is_empty() {
10647            out.push_str("  No drivers retrieved via WMI.\n");
10648        } else {
10649            out.push_str(&output);
10650        }
10651    }
10652
10653    #[cfg(not(target_os = "windows"))]
10654    {
10655        out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
10656        let lsmod = Command::new("lsmod")
10657            .output()
10658            .ok()
10659            .and_then(|o| String::from_utf8(o.stdout).ok())
10660            .unwrap_or_default();
10661        out.push_str(
10662            &lsmod
10663                .lines()
10664                .take(max_entries)
10665                .collect::<Vec<_>>()
10666                .join("\n"),
10667        );
10668    }
10669
10670    Ok(out.trim_end().to_string())
10671}
10672
10673fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
10674    let mut out = String::from("Host inspection: peripherals\n\n");
10675
10676    #[cfg(target_os = "windows")]
10677    {
10678        let _ = max_entries;
10679        out.push_str("=== USB Controllers & Hubs ===\n");
10680        let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \"  $($_.Name) ($($_.Status))\" }"])
10681            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10682        out.push_str(if usb.is_empty() {
10683            "  None detected.\n"
10684        } else {
10685            &usb
10686        });
10687
10688        out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
10689        let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \"  [KB] $($_.Name) ($($_.Status))\" }"])
10690            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10691        let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \"  [PTR] $($_.Name) ($($_.Status))\" }"])
10692            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10693        out.push_str(&kb);
10694        out.push_str(&mouse);
10695
10696        out.push_str("\n=== Connected Monitors (WMI) ===\n");
10697        let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \"  Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
10698            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
10699        out.push_str(if mon.is_empty() {
10700            "  No active monitors identified via WMI.\n"
10701        } else {
10702            &mon
10703        });
10704    }
10705
10706    #[cfg(not(target_os = "windows"))]
10707    {
10708        out.push_str("=== Connected USB Devices (lsusb) ===\n");
10709        let lsusb = Command::new("lsusb")
10710            .output()
10711            .ok()
10712            .and_then(|o| String::from_utf8(o.stdout).ok())
10713            .unwrap_or_default();
10714        out.push_str(
10715            &lsusb
10716                .lines()
10717                .take(max_entries)
10718                .collect::<Vec<_>>()
10719                .join("\n"),
10720        );
10721    }
10722
10723    Ok(out.trim_end().to_string())
10724}
10725
10726fn inspect_sessions(max_entries: usize) -> Result<String, String> {
10727    let mut out = String::from("Host inspection: sessions\n\n");
10728
10729    #[cfg(target_os = "windows")]
10730    {
10731        out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
10732        let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
10733    "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
10734}"#;
10735        if let Ok(o) = Command::new("powershell")
10736            .args(["-NoProfile", "-Command", script])
10737            .output()
10738        {
10739            let text = String::from_utf8_lossy(&o.stdout);
10740            let lines: Vec<&str> = text.lines().collect();
10741            if lines.is_empty() {
10742                out.push_str("  No active logon sessions enumerated via WMI.\n");
10743            } else {
10744                for line in lines
10745                    .iter()
10746                    .take(max_entries)
10747                    .filter(|l| !l.trim().is_empty())
10748                {
10749                    let parts: Vec<&str> = line.trim().split('|').collect();
10750                    if parts.len() == 4 {
10751                        let logon_type = match parts[2] {
10752                            "2" => "Interactive",
10753                            "3" => "Network",
10754                            "4" => "Batch",
10755                            "5" => "Service",
10756                            "7" => "Unlock",
10757                            "8" => "NetworkCleartext",
10758                            "9" => "NewCredentials",
10759                            "10" => "RemoteInteractive",
10760                            "11" => "CachedInteractive",
10761                            _ => "Other",
10762                        };
10763                        out.push_str(&format!(
10764                            "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
10765                            parts[0], logon_type, parts[1], parts[3]
10766                        ));
10767                    }
10768                }
10769            }
10770        } else {
10771            out.push_str("  Active logon session data unavailable from WMI.\n");
10772        }
10773    }
10774
10775    #[cfg(not(target_os = "windows"))]
10776    {
10777        out.push_str("=== Logged-in Users (who) ===\n");
10778        let who = Command::new("who")
10779            .output()
10780            .ok()
10781            .and_then(|o| String::from_utf8(o.stdout).ok())
10782            .unwrap_or_default();
10783        out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
10784    }
10785
10786    Ok(out.trim_end().to_string())
10787}
10788
10789async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
10790    let mut out = String::from("Host inspection: disk_benchmark\n\n");
10791    let mut final_path = path;
10792
10793    if !final_path.exists() {
10794        if let Ok(current_exe) = std::env::current_exe() {
10795            out.push_str(&format!(
10796                "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
10797                final_path.display()
10798            ));
10799            final_path = current_exe;
10800        } else {
10801            return Err(format!("Target not found: {}", final_path.display()));
10802        }
10803    }
10804
10805    let target = if final_path.is_dir() {
10806        // Find a representative file to read
10807        let mut target_file = final_path.join("Cargo.toml");
10808        if !target_file.exists() {
10809            target_file = final_path.join("README.md");
10810        }
10811        if !target_file.exists() {
10812            return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
10813        }
10814        target_file
10815    } else {
10816        final_path
10817    };
10818
10819    out.push_str(&format!("Target: {}\n", target.display()));
10820    out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
10821
10822    #[cfg(target_os = "windows")]
10823    {
10824        let script = format!(
10825            r#"
10826$target = "{}"
10827if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
10828
10829$diskQueue = @()
10830$readStats = @()
10831$startTime = Get-Date
10832$duration = 5
10833
10834# Background reader job
10835$job = Start-Job -ScriptBlock {{
10836    param($t, $d)
10837    $stop = (Get-Date).AddSeconds($d)
10838    while ((Get-Date) -lt $stop) {{
10839        try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
10840    }}
10841}} -ArgumentList $target, $duration
10842
10843# Metrics collector loop
10844$stopTime = (Get-Date).AddSeconds($duration)
10845while ((Get-Date) -lt $stopTime) {{
10846    $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
10847    if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
10848    
10849    $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
10850    if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
10851    
10852    Start-Sleep -Milliseconds 250
10853}}
10854
10855Stop-Job $job
10856Receive-Job $job | Out-Null
10857Remove-Job $job
10858
10859$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
10860$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
10861$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
10862
10863"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
10864"#,
10865            target.display()
10866        );
10867
10868        let output = Command::new("powershell")
10869            .args(["-NoProfile", "-Command", &script])
10870            .output()
10871            .map_err(|e| format!("Benchmark failed: {e}"))?;
10872
10873        let raw = String::from_utf8_lossy(&output.stdout);
10874        let text = raw.trim();
10875
10876        if text.starts_with("ERROR") {
10877            return Err(text.to_string());
10878        }
10879
10880        let mut lines = text.lines();
10881        if let Some(metrics_line) = lines.next() {
10882            let parts: Vec<&str> = metrics_line.split('|').collect();
10883            let mut avg_q = "unknown".to_string();
10884            let mut max_q = "unknown".to_string();
10885            let mut avg_r = "unknown".to_string();
10886
10887            for p in parts {
10888                if let Some((k, v)) = p.split_once(':') {
10889                    match k {
10890                        "AVG_Q" => avg_q = v.to_string(),
10891                        "MAX_Q" => max_q = v.to_string(),
10892                        "AVG_R" => avg_r = v.to_string(),
10893                        _ => {}
10894                    }
10895                }
10896            }
10897
10898            out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
10899            out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
10900            out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
10901            out.push_str(&format!("- Disk Throughput (Avg):  {} reads/sec\n", avg_r));
10902            out.push_str("\nVerdict: ");
10903            let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
10904            if q_num > 1.0 {
10905                out.push_str(
10906                    "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
10907                );
10908            } else if q_num > 0.1 {
10909                out.push_str("MODERATE LOAD — significant I/O pressure detected.");
10910            } else {
10911                out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
10912            }
10913        }
10914    }
10915
10916    #[cfg(not(target_os = "windows"))]
10917    {
10918        out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
10919        out.push_str("Generic disk load simulated.\n");
10920    }
10921
10922    Ok(out)
10923}
10924
10925fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
10926    let mut out = String::from("Host inspection: permissions\n\n");
10927    out.push_str(&format!(
10928        "Auditing access control for: {}\n\n",
10929        path.display()
10930    ));
10931
10932    #[cfg(target_os = "windows")]
10933    {
10934        let script = format!(
10935            "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
10936            path.display()
10937        );
10938        let output = Command::new("powershell")
10939            .args(["-NoProfile", "-Command", &script])
10940            .output()
10941            .map_err(|e| format!("ACL check failed: {e}"))?;
10942
10943        let text = String::from_utf8_lossy(&output.stdout);
10944        if text.trim().is_empty() {
10945            out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
10946        } else {
10947            out.push_str("=== Windows NTFS Permissions ===\n");
10948            out.push_str(&text);
10949        }
10950    }
10951
10952    #[cfg(not(target_os = "windows"))]
10953    {
10954        let output = Command::new("ls")
10955            .args(["-ld", &path.to_string_lossy()])
10956            .output()
10957            .map_err(|e| format!("ls check failed: {e}"))?;
10958        out.push_str("=== Unix File Permissions ===\n");
10959        out.push_str(&String::from_utf8_lossy(&output.stdout));
10960    }
10961
10962    Ok(out.trim_end().to_string())
10963}
10964
10965fn inspect_login_history(max_entries: usize) -> Result<String, String> {
10966    let mut out = String::from("Host inspection: login_history\n\n");
10967
10968    #[cfg(target_os = "windows")]
10969    {
10970        out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
10971        out.push_str("Note: This typically requires Administrator elevation.\n\n");
10972
10973        let n = max_entries.clamp(1, 50);
10974        let script = format!(
10975            r#"try {{
10976    $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
10977    $events | ForEach-Object {{
10978        $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
10979        # Extract target user name from the XML/Properties if possible
10980        $user = $_.Properties[5].Value
10981        $type = $_.Properties[8].Value
10982        "[$time] User: $user | Type: $type"
10983    }}
10984}} catch {{ "ERROR:" + $_.Exception.Message }}"#
10985        );
10986
10987        let output = Command::new("powershell")
10988            .args(["-NoProfile", "-Command", &script])
10989            .output()
10990            .map_err(|e| format!("Login history query failed: {e}"))?;
10991
10992        let text = String::from_utf8_lossy(&output.stdout);
10993        if text.starts_with("ERROR:") {
10994            out.push_str(&format!("Unable to query Security Log: {}\n", text));
10995        } else if text.trim().is_empty() {
10996            out.push_str("No recent logon events found or access denied.\n");
10997        } else {
10998            out.push_str("=== Recent Logons (Event ID 4624) ===\n");
10999            out.push_str(&text);
11000        }
11001    }
11002
11003    #[cfg(not(target_os = "windows"))]
11004    {
11005        let output = Command::new("last")
11006            .args(["-n", &max_entries.to_string()])
11007            .output()
11008            .map_err(|e| format!("last command failed: {e}"))?;
11009        out.push_str("=== Unix Login History (last) ===\n");
11010        out.push_str(&String::from_utf8_lossy(&output.stdout));
11011    }
11012
11013    Ok(out.trim_end().to_string())
11014}
11015
11016fn inspect_share_access(path: PathBuf) -> Result<String, String> {
11017    let mut out = String::from("Host inspection: share_access\n\n");
11018    out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
11019
11020    #[cfg(target_os = "windows")]
11021    {
11022        let script = format!(
11023            r#"
11024$p = '{}'
11025$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
11026if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
11027    $res.Reachable = $true
11028    try {{
11029        $null = Get-ChildItem -Path $p -ErrorAction Stop
11030        $res.Readable = $true
11031    }} catch {{
11032        $res.Error = $_.Exception.Message
11033    }}
11034}} else {{
11035    $res.Error = "Server unreachable (Ping failed)"
11036}}
11037"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
11038            path.display()
11039        );
11040
11041        let output = Command::new("powershell")
11042            .args(["-NoProfile", "-Command", &script])
11043            .output()
11044            .map_err(|e| format!("Share test failed: {e}"))?;
11045
11046        let text = String::from_utf8_lossy(&output.stdout);
11047        out.push_str("=== Share Triage Results ===\n");
11048        out.push_str(&text);
11049    }
11050
11051    #[cfg(not(target_os = "windows"))]
11052    {
11053        out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
11054    }
11055
11056    Ok(out.trim_end().to_string())
11057}
11058
11059fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
11060    let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
11061    out.push_str(&format!("Issue: {}\n\n", issue));
11062    out.push_str("Proposed Remediation Steps:\n");
11063    out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
11064    out.push_str("   `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
11065    out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
11066    out.push_str("   `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
11067    out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
11068    out.push_str("   `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
11069    out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
11070    out.push_str(
11071        "   `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
11072    );
11073
11074    Ok(out)
11075}
11076
11077fn inspect_registry_audit() -> Result<String, String> {
11078    let mut out = String::from("Host inspection: registry_audit\n\n");
11079    out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
11080
11081    #[cfg(target_os = "windows")]
11082    {
11083        let script = r#"
11084$findings = @()
11085
11086# 1. Image File Execution Options (Debugger Hijacking)
11087$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
11088if (Test-Path $ifeo) {
11089    Get-ChildItem $ifeo | ForEach-Object {
11090        $p = Get-ItemProperty $_.PSPath
11091        if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
11092    }
11093}
11094
11095# 2. Winlogon Shell Integrity
11096$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
11097$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
11098if ($shell -and $shell -ne "explorer.exe") {
11099    $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
11100}
11101
11102# 3. Session Manager BootExecute
11103$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
11104$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
11105if ($boot -and $boot -notcontains "autocheck autochk *") {
11106    $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
11107}
11108
11109if ($findings.Count -eq 0) {
11110    "PASS: No common registry hijacking or shell overrides detected."
11111} else {
11112    $findings -join "`n"
11113}
11114"#;
11115        let output = Command::new("powershell")
11116            .args(["-NoProfile", "-Command", &script])
11117            .output()
11118            .map_err(|e| format!("Registry audit failed: {e}"))?;
11119
11120        let text = String::from_utf8_lossy(&output.stdout);
11121        out.push_str("=== Persistence & Integrity Check ===\n");
11122        out.push_str(&text);
11123    }
11124
11125    #[cfg(not(target_os = "windows"))]
11126    {
11127        out.push_str("Registry auditing is specific to Windows environments.\n");
11128    }
11129
11130    Ok(out.trim_end().to_string())
11131}
11132
11133fn inspect_thermal() -> Result<String, String> {
11134    let mut out = String::from("Host inspection: thermal\n\n");
11135    out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
11136
11137    #[cfg(target_os = "windows")]
11138    {
11139        let script = r#"
11140$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
11141if ($thermal) {
11142    $thermal | ForEach-Object {
11143        $temp = [math]::Round(($_.Temperature - 273.15), 1)
11144        "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
11145    }
11146} else {
11147    "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
11148    $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
11149    "Current CPU Load: $throttling%"
11150}
11151"#;
11152        let output = Command::new("powershell")
11153            .args(["-NoProfile", "-Command", script])
11154            .output()
11155            .map_err(|e| format!("Thermal check failed: {e}"))?;
11156        out.push_str("=== Windows Thermal State ===\n");
11157        out.push_str(&String::from_utf8_lossy(&output.stdout));
11158    }
11159
11160    #[cfg(not(target_os = "windows"))]
11161    {
11162        out.push_str(
11163            "Thermal inspection is currently optimized for Windows performance counters.\n",
11164        );
11165    }
11166
11167    Ok(out.trim_end().to_string())
11168}
11169
11170fn inspect_activation() -> Result<String, String> {
11171    let mut out = String::from("Host inspection: activation\n\n");
11172    out.push_str("Auditing Windows activation and license state...\n\n");
11173
11174    #[cfg(target_os = "windows")]
11175    {
11176        let script = r#"
11177$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
11178$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
11179"Status: $($xpr.Trim())"
11180"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
11181"#;
11182        let output = Command::new("powershell")
11183            .args(["-NoProfile", "-Command", script])
11184            .output()
11185            .map_err(|e| format!("Activation check failed: {e}"))?;
11186        out.push_str("=== Windows License Report ===\n");
11187        out.push_str(&String::from_utf8_lossy(&output.stdout));
11188    }
11189
11190    #[cfg(not(target_os = "windows"))]
11191    {
11192        out.push_str("Windows activation check is specific to the Windows platform.\n");
11193    }
11194
11195    Ok(out.trim_end().to_string())
11196}
11197
11198fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
11199    let mut out = String::from("Host inspection: patch_history\n\n");
11200    out.push_str(&format!(
11201        "Listing the last {} installed Windows updates (KBs)...\n\n",
11202        max_entries
11203    ));
11204
11205    #[cfg(target_os = "windows")]
11206    {
11207        let n = max_entries.clamp(1, 50);
11208        let script = format!(
11209            "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
11210            n
11211        );
11212        let output = Command::new("powershell")
11213            .args(["-NoProfile", "-Command", &script])
11214            .output()
11215            .map_err(|e| format!("Patch history query failed: {e}"))?;
11216        out.push_str("=== Recent HotFixes (KBs) ===\n");
11217        out.push_str(&String::from_utf8_lossy(&output.stdout));
11218    }
11219
11220    #[cfg(not(target_os = "windows"))]
11221    {
11222        out.push_str("Patch history is currently focused on Windows HotFixes.\n");
11223    }
11224
11225    Ok(out.trim_end().to_string())
11226}
11227
11228// ── ad_user ──────────────────────────────────────────────────────────────────
11229
11230fn inspect_ad_user(identity: &str) -> Result<String, String> {
11231    let mut out = String::from("Host inspection: ad_user\n\n");
11232    let ident = identity.trim();
11233    if ident.is_empty() {
11234        out.push_str("Status: No identity specified. Performing self-discovery...\n");
11235        #[cfg(target_os = "windows")]
11236        {
11237            let script = r#"
11238$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
11239"USER: " + $u.Name
11240"SID: " + $u.User.Value
11241"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
11242"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
11243"#;
11244            let output = Command::new("powershell")
11245                .args(["-NoProfile", "-Command", script])
11246                .output()
11247                .ok();
11248            if let Some(o) = output {
11249                out.push_str(&String::from_utf8_lossy(&o.stdout));
11250            }
11251        }
11252        return Ok(out);
11253    }
11254
11255    #[cfg(target_os = "windows")]
11256    {
11257        let script = format!(
11258            r#"
11259try {{
11260    $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
11261    "NAME: " + $u.Name
11262    "SID: " + $u.SID
11263    "ENABLED: " + $u.Enabled
11264    "EXPIRED: " + $u.PasswordExpired
11265    "LOGON: " + $u.LastLogonDate
11266    "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
11267}} catch {{
11268    # Fallback to net user if AD module is missing or fails
11269    $net = net user "{ident}" /domain 2>&1
11270    if ($LASTEXITCODE -eq 0) {{
11271        $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
11272    }} else {{
11273        "ERROR: " + $_.Exception.Message
11274    }}
11275}}"#
11276        );
11277
11278        let output = Command::new("powershell")
11279            .args(["-NoProfile", "-Command", &script])
11280            .output()
11281            .ok();
11282
11283        if let Some(o) = output {
11284            let stdout = String::from_utf8_lossy(&o.stdout);
11285            if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
11286                out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
11287            }
11288            out.push_str(&stdout);
11289        }
11290    }
11291
11292    #[cfg(not(target_os = "windows"))]
11293    {
11294        let _ = ident;
11295        out.push_str("(AD User lookup only available on Windows nodes)\n");
11296    }
11297
11298    Ok(out.trim_end().to_string())
11299}
11300
11301// ── dns_lookup ───────────────────────────────────────────────────────────────
11302
11303fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
11304    let mut out = String::from("Host inspection: dns_lookup\n\n");
11305    let target = name.trim();
11306    if target.is_empty() {
11307        return Err("Missing required target name for dns_lookup.".to_string());
11308    }
11309
11310    #[cfg(target_os = "windows")]
11311    {
11312        let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
11313        let output = Command::new("powershell")
11314            .args(["-NoProfile", "-Command", &script])
11315            .output()
11316            .ok();
11317        if let Some(o) = output {
11318            let stdout = String::from_utf8_lossy(&o.stdout);
11319            if stdout.trim().is_empty() {
11320                out.push_str(&format!("No {record_type} records found for {target}.\n"));
11321            } else {
11322                out.push_str(&stdout);
11323            }
11324        }
11325    }
11326
11327    #[cfg(not(target_os = "windows"))]
11328    {
11329        let output = Command::new("dig")
11330            .args([target, record_type, "+short"])
11331            .output()
11332            .ok();
11333        if let Some(o) = output {
11334            out.push_str(&String::from_utf8_lossy(&o.stdout));
11335        }
11336    }
11337
11338    Ok(out.trim_end().to_string())
11339}
11340
11341// ── hyperv ───────────────────────────────────────────────────────────────────
11342
11343fn inspect_hyperv() -> Result<String, String> {
11344    let mut out = String::from("Host inspection: hyperv\n\n");
11345
11346    #[cfg(target_os = "windows")]
11347    {
11348        let script = "Get-VM -ErrorAction SilentlyContinue | Select-Object Name, State, Uptime, Status, CPUUsage, MemoryAssigned | Format-Table -AutoSize";
11349        let output = Command::new("powershell")
11350            .args(["-NoProfile", "-Command", script])
11351            .output()
11352            .ok();
11353        if let Some(o) = output {
11354            let stdout = String::from_utf8_lossy(&o.stdout);
11355            if stdout.trim().is_empty() {
11356                out.push_str(
11357                    "No Hyper-V Virtual Machines found or Hyper-V module not installed.\n",
11358                );
11359            } else {
11360                out.push_str(&stdout);
11361            }
11362        }
11363    }
11364
11365    #[cfg(not(target_os = "windows"))]
11366    {
11367        out.push_str("(Hyper-V lookup only available on Windows hosts)\n");
11368    }
11369
11370    Ok(out.trim_end().to_string())
11371}
11372
11373// ── ip_config ────────────────────────────────────────────────────────────────
11374
11375fn inspect_ip_config() -> Result<String, String> {
11376    let mut out = String::from("Host inspection: ip_config\n\n");
11377
11378    #[cfg(target_os = "windows")]
11379    {
11380        let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
11381            $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
11382            '\\n  Status: ' + $_.NetAdapter.Status + \
11383            '\\n  Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
11384            '\\n  DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
11385            '\\n  DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11386            '\\n  IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
11387            '\\n  DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
11388        }";
11389        let output = Command::new("powershell")
11390            .args(["-NoProfile", "-Command", script])
11391            .output()
11392            .ok();
11393        if let Some(o) = output {
11394            out.push_str(&String::from_utf8_lossy(&o.stdout));
11395        }
11396    }
11397
11398    #[cfg(not(target_os = "windows"))]
11399    {
11400        let output = Command::new("ip").args(["addr", "show"]).output().ok();
11401        if let Some(o) = output {
11402            out.push_str(&String::from_utf8_lossy(&o.stdout));
11403        }
11404    }
11405
11406    Ok(out.trim_end().to_string())
11407}
11408
11409#[cfg(target_os = "windows")]
11410fn gpu_voltage_telemetry_note() -> String {
11411    let output = Command::new("nvidia-smi")
11412        .args(["--help-query-gpu"])
11413        .output();
11414
11415    match output {
11416        Ok(o) => {
11417            let text = String::from_utf8_lossy(&o.stdout).to_ascii_lowercase();
11418            if text.contains("\"voltage\"") || text.contains("voltage.") {
11419                "Driver query surface advertises GPU voltage fields, but Hematite is not yet decoding them on this path.".to_string()
11420            } else {
11421                "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()
11422            }
11423        }
11424        Err(_) => "Unavailable: `nvidia-smi` is not present, so Hematite cannot verify whether this driver path exposes GPU voltage rails.".to_string(),
11425    }
11426}
11427
11428#[cfg(target_os = "windows")]
11429fn decode_wmi_processor_voltage(raw: u64) -> Option<String> {
11430    if raw == 0 {
11431        return None;
11432    }
11433    if raw & 0x80 != 0 {
11434        let tenths = raw & 0x7f;
11435        return Some(format!(
11436            "{:.1} V (firmware-reported WMI current voltage)",
11437            tenths as f64 / 10.0
11438        ));
11439    }
11440
11441    let legacy = match raw {
11442        1 => Some("5.0 V"),
11443        2 => Some("3.3 V"),
11444        4 => Some("2.9 V"),
11445        _ => None,
11446    }?;
11447    Some(format!(
11448        "{} (legacy WMI voltage capability flag, not live telemetry)",
11449        legacy
11450    ))
11451}
11452
11453async fn inspect_overclocker() -> Result<String, String> {
11454    let mut out = String::from("Host inspection: overclocker\n\n");
11455
11456    #[cfg(target_os = "windows")]
11457    {
11458        out.push_str(
11459            "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
11460        );
11461
11462        // 1. NVIDIA Census
11463        let nvidia = Command::new("nvidia-smi")
11464            .args([
11465                "--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",
11466                "--format=csv,noheader,nounits",
11467            ])
11468            .output();
11469
11470        if let Ok(o) = nvidia {
11471            let stdout = String::from_utf8_lossy(&o.stdout);
11472            if !stdout.trim().is_empty() {
11473                out.push_str("=== GPU SENSE (NVIDIA) ===\n");
11474                let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
11475                if parts.len() >= 10 {
11476                    out.push_str(&format!("- Model:      {}\n", parts[0]));
11477                    out.push_str(&format!("- Graphics:   {} MHz\n", parts[1]));
11478                    out.push_str(&format!("- Memory:     {} MHz\n", parts[2]));
11479                    out.push_str(&format!("- Fan Speed:  {}%\n", parts[3]));
11480                    out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
11481                    if !parts[6].eq_ignore_ascii_case("[N/A]") {
11482                        out.push_str(&format!("- Power Avg:  {} W\n", parts[6]));
11483                    }
11484                    if !parts[7].eq_ignore_ascii_case("[N/A]") {
11485                        out.push_str(&format!("- Power Inst: {} W\n", parts[7]));
11486                    }
11487                    if !parts[8].eq_ignore_ascii_case("[N/A]") {
11488                        out.push_str(&format!("- Power Cap:  {} W requested\n", parts[8]));
11489                    }
11490                    if !parts[9].eq_ignore_ascii_case("[N/A]") {
11491                        out.push_str(&format!("- Power Enf:  {} W enforced\n", parts[9]));
11492                    }
11493                    out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
11494
11495                    if parts.len() > 10 {
11496                        let throttle_hex = parts[10];
11497                        let reasons = decode_nvidia_throttle_reasons(throttle_hex);
11498                        if !reasons.is_empty() {
11499                            out.push_str(&format!("- Throttling:  YES [Reason: {}]\n", reasons));
11500                        } else {
11501                            out.push_str("- Throttling:  None (Performance State: Max)\n");
11502                        }
11503                    }
11504                }
11505                out.push_str("\n");
11506            }
11507        }
11508
11509        out.push_str("=== VOLTAGE TELEMETRY ===\n");
11510        out.push_str(&format!(
11511            "- GPU Voltage:  {}\n\n",
11512            gpu_voltage_telemetry_note()
11513        ));
11514
11515        // 1b. Session Trends (RAM-only historians)
11516        let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
11517        let history = gpu_state.history.lock().unwrap();
11518        if history.len() >= 2 {
11519            out.push_str("=== SILICON TRENDS (Session) ===\n");
11520            let first = history.front().unwrap();
11521            let last = history.back().unwrap();
11522
11523            let temp_diff = last.temperature as i32 - first.temperature as i32;
11524            let clock_diff = last.core_clock as i32 - first.core_clock as i32;
11525
11526            let temp_trend = if temp_diff > 1 {
11527                "Rising"
11528            } else if temp_diff < -1 {
11529                "Falling"
11530            } else {
11531                "Stable"
11532            };
11533            let clock_trend = if clock_diff > 10 {
11534                "Increasing"
11535            } else if clock_diff < -10 {
11536                "Decreasing"
11537            } else {
11538                "Stable"
11539            };
11540
11541            out.push_str(&format!(
11542                "- Temperature: {} ({}°C anomaly)\n",
11543                temp_trend, temp_diff
11544            ));
11545            out.push_str(&format!(
11546                "- Core Clock:  {} ({} MHz delta)\n",
11547                clock_trend, clock_diff
11548            ));
11549            out.push_str("\n");
11550        }
11551
11552        // 2. CPU Time-Series (2 samples)
11553        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))\" }";
11554        let cpu_stats = Command::new("powershell")
11555            .args(["-NoProfile", "-Command", ps_cmd])
11556            .output();
11557
11558        if let Ok(o) = cpu_stats {
11559            let stdout = String::from_utf8_lossy(&o.stdout);
11560            if !stdout.trim().is_empty() {
11561                out.push_str("=== SILICON CORE (CPU) ===\n");
11562                for line in stdout.lines() {
11563                    if let Some((path, val)) = line.split_once(':') {
11564                        if path.to_lowercase().contains("processor frequency") {
11565                            out.push_str(&format!("- Current Freq:  {} MHz (2s Avg)\n", val));
11566                        } else if path.to_lowercase().contains("% of maximum frequency") {
11567                            out.push_str(&format!("- Throttling:     {}% of Max Capacity\n", val));
11568                            let throttle_num = val.parse::<f64>().unwrap_or(100.0);
11569                            if throttle_num < 95.0 {
11570                                out.push_str(
11571                                    "  [WARNING] Active downclocking or power-saving detected.\n",
11572                                );
11573                            }
11574                        }
11575                    }
11576                }
11577            }
11578        }
11579
11580        // 2b. CPU Thermal Fallback
11581        let thermal = Command::new("powershell")
11582            .args([
11583                "-NoProfile",
11584                "-Command",
11585                "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
11586            ])
11587            .output();
11588        if let Ok(o) = thermal {
11589            let stdout = String::from_utf8_lossy(&o.stdout);
11590            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
11591                let temp = if v.is_array() {
11592                    v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
11593                } else {
11594                    v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
11595                };
11596                if temp > 1.0 {
11597                    out.push_str(&format!("- CPU Package:   {}°C (ACPI Zone)\n", temp));
11598                }
11599            }
11600        }
11601
11602        // 3. WMI Static Fallback/Context
11603        let wmi = Command::new("powershell")
11604            .args([
11605                "-NoProfile",
11606                "-Command",
11607                "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
11608            ])
11609            .output();
11610
11611        if let Ok(o) = wmi {
11612            let stdout = String::from_utf8_lossy(&o.stdout);
11613            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
11614                out.push_str("\n=== HARDWARE DNA ===\n");
11615                out.push_str(&format!(
11616                    "- Rated Max:     {} MHz\n",
11617                    v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
11618                ));
11619                match v.get("CurrentVoltage").and_then(|x| x.as_u64()) {
11620                    Some(raw) => {
11621                        if let Some(decoded) = decode_wmi_processor_voltage(raw) {
11622                            out.push_str(&format!("- CPU Voltage:   {}\n", decoded));
11623                        } else {
11624                            out.push_str(
11625                                "- CPU Voltage:   Unavailable or non-telemetry WMI value on this firmware path\n",
11626                            );
11627                        }
11628                    }
11629                    None => out.push_str("- CPU Voltage:   Unavailable on this WMI path\n"),
11630                }
11631            }
11632        }
11633    }
11634
11635    #[cfg(not(target_os = "windows"))]
11636    {
11637        out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
11638    }
11639
11640    Ok(out.trim_end().to_string())
11641}
11642
11643/// Decodes the NVIDIA Clocks Throttle Reasons HEX bitmask.
11644#[cfg(target_os = "windows")]
11645fn decode_nvidia_throttle_reasons(hex: &str) -> String {
11646    let hex = hex.trim().trim_start_matches("0x");
11647    let val = match u64::from_str_radix(hex, 16) {
11648        Ok(v) => v,
11649        Err(_) => return String::new(),
11650    };
11651
11652    if val == 0 {
11653        return String::new();
11654    }
11655
11656    let mut reasons = Vec::new();
11657    if val & 0x01 != 0 {
11658        reasons.push("GPU Idle");
11659    }
11660    if val & 0x02 != 0 {
11661        reasons.push("Applications Clocks Setting");
11662    }
11663    if val & 0x04 != 0 {
11664        reasons.push("SW Power Cap (PL1/PL2)");
11665    }
11666    if val & 0x08 != 0 {
11667        reasons.push("HW Slowdown (Thermal/Power)");
11668    }
11669    if val & 0x10 != 0 {
11670        reasons.push("Sync Boost");
11671    }
11672    if val & 0x20 != 0 {
11673        reasons.push("SW Thermal Slowdown");
11674    }
11675    if val & 0x40 != 0 {
11676        reasons.push("HW Thermal Slowdown");
11677    }
11678    if val & 0x80 != 0 {
11679        reasons.push("HW Power Brake Slowdown");
11680    }
11681    if val & 0x100 != 0 {
11682        reasons.push("Display Clock Setting");
11683    }
11684
11685    reasons.join(", ")
11686}
11687
11688// ── PowerShell helper (used by camera / sign_in / search_index) ───────────────
11689
11690#[cfg(windows)]
11691fn run_powershell(script: &str) -> Result<String, String> {
11692    use std::process::Command;
11693    let out = Command::new("powershell")
11694        .args(["-NoProfile", "-NonInteractive", "-Command", script])
11695        .output()
11696        .map_err(|e| format!("powershell launch failed: {e}"))?;
11697    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
11698}
11699
11700// ── inspect_camera ────────────────────────────────────────────────────────────
11701
11702#[cfg(windows)]
11703fn inspect_camera(max_entries: usize) -> Result<String, String> {
11704    let mut out = String::from("=== Camera devices ===\n");
11705
11706    // PnP camera devices
11707    let ps_devices = r#"
11708Get-PnpDevice -Class Camera -ErrorAction SilentlyContinue | Select-Object -First 20 |
11709ForEach-Object {
11710    $status = if ($_.Status -eq 'OK') { 'OK' } else { $_.Status }
11711    "$($_.FriendlyName) | Status: $status | InstanceId: $($_.InstanceId)"
11712}
11713"#;
11714    match run_powershell(ps_devices) {
11715        Ok(o) if !o.trim().is_empty() => {
11716            for line in o.lines().take(max_entries) {
11717                let l = line.trim();
11718                if !l.is_empty() {
11719                    out.push_str(&format!("- {l}\n"));
11720                }
11721            }
11722        }
11723        _ => out.push_str("- No camera devices found via PnP\n"),
11724    }
11725
11726    // Windows privacy / capability gate
11727    out.push_str("\n=== Windows camera privacy ===\n");
11728    let ps_privacy = r#"
11729$camKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\webcam'
11730$global = (Get-ItemProperty -Path $camKey -Name Value -ErrorAction SilentlyContinue).Value
11731"Global: $global"
11732$apps = Get-ChildItem $camKey -ErrorAction SilentlyContinue |
11733    Where-Object { $_.PSChildName -ne 'NonPackaged' } |
11734    ForEach-Object {
11735        $v = (Get-ItemProperty $_.PSPath -Name Value -ErrorAction SilentlyContinue).Value
11736        if ($v) { "  $($_.PSChildName): $v" }
11737    }
11738$apps
11739"#;
11740    match run_powershell(ps_privacy) {
11741        Ok(o) if !o.trim().is_empty() => {
11742            for line in o.lines().take(max_entries) {
11743                let l = line.trim_end();
11744                if !l.is_empty() {
11745                    out.push_str(&format!("{l}\n"));
11746                }
11747            }
11748        }
11749        _ => out.push_str("- Could not read camera privacy registry\n"),
11750    }
11751
11752    // Windows Hello camera (IR / face auth)
11753    out.push_str("\n=== Biometric / Hello camera ===\n");
11754    let ps_bio = r#"
11755Get-PnpDevice -Class Biometric -ErrorAction SilentlyContinue | Select-Object -First 10 |
11756ForEach-Object { "$($_.FriendlyName) | Status: $($_.Status)" }
11757"#;
11758    match run_powershell(ps_bio) {
11759        Ok(o) if !o.trim().is_empty() => {
11760            for line in o.lines().take(max_entries) {
11761                let l = line.trim();
11762                if !l.is_empty() {
11763                    out.push_str(&format!("- {l}\n"));
11764                }
11765            }
11766        }
11767        _ => out.push_str("- No biometric devices found\n"),
11768    }
11769
11770    // Findings
11771    let mut findings: Vec<String> = Vec::new();
11772    if out.contains("Status: Error") || out.contains("Status: Unknown") {
11773        findings.push("One or more camera devices report a non-OK status — check Device Manager for driver errors.".into());
11774    }
11775    if out.contains("Global: Deny") {
11776        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());
11777    }
11778
11779    let mut result = String::from("Host inspection: camera\n\n=== Findings ===\n");
11780    if findings.is_empty() {
11781        result.push_str("- No obvious camera or privacy gate issue detected.\n");
11782        result.push_str("  If an app still can't see the camera, check its individual permission in Settings > Privacy > Camera.\n");
11783    } else {
11784        for f in &findings {
11785            result.push_str(&format!("- Finding: {f}\n"));
11786        }
11787    }
11788    result.push('\n');
11789    result.push_str(&out);
11790    Ok(result)
11791}
11792
11793#[cfg(not(windows))]
11794fn inspect_camera(_max_entries: usize) -> Result<String, String> {
11795    Ok("Host inspection: camera\nCamera inspection is Windows-only.".into())
11796}
11797
11798// ── inspect_sign_in ───────────────────────────────────────────────────────────
11799
11800#[cfg(windows)]
11801fn inspect_sign_in(max_entries: usize) -> Result<String, String> {
11802    let mut out = String::from("=== Windows Hello and sign-in state ===\n");
11803
11804    // Windows Hello PIN and face/fingerprint readiness
11805    let ps_hello = r#"
11806$helloKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI'
11807$pinConfigured = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Provisioning\FingerPrint' -ErrorAction SilentlyContinue
11808$faceConfigured = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\WbioSrvc' -Name Start -ErrorAction SilentlyContinue).Start
11809"PIN-style logon path: $helloKey"
11810"WbioSrvc start type: $faceConfigured"
11811"FingerPrint key present: $pinConfigured"
11812"#;
11813    match run_powershell(ps_hello) {
11814        Ok(o) => {
11815            for line in o.lines().take(max_entries) {
11816                let l = line.trim();
11817                if !l.is_empty() {
11818                    out.push_str(&format!("- {l}\n"));
11819                }
11820            }
11821        }
11822        Err(e) => out.push_str(&format!("- Hello query error: {e}\n")),
11823    }
11824
11825    // Biometric service state
11826    out.push_str("\n=== Biometric service ===\n");
11827    let ps_bio_svc = r#"
11828$svc = Get-Service WbioSrvc -ErrorAction SilentlyContinue
11829if ($svc) { "WbioSrvc | Status: $($svc.Status) | StartType: $($svc.StartType)" }
11830else { "WbioSrvc not found" }
11831"#;
11832    match run_powershell(ps_bio_svc) {
11833        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
11834        Err(_) => out.push_str("- Could not query biometric service\n"),
11835    }
11836
11837    // Recent logon failure events
11838    out.push_str("\n=== Recent sign-in failures (last 24h) ===\n");
11839    let ps_events = r#"
11840$cutoff = (Get-Date).AddHours(-24)
11841Get-WinEvent -LogName Security -FilterXPath "*[System[EventID=4625 and TimeCreated[timediff(@SystemTime) <= 86400000]]]" -MaxEvents 10 -ErrorAction SilentlyContinue |
11842ForEach-Object {
11843    $xml = [xml]$_.ToXml()
11844    $reason = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'FailureReason' }).'#text'
11845    $account = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text'
11846    "$($_.TimeCreated.ToString('HH:mm')) | Account: $account | Reason: $reason"
11847} | Select-Object -First 10
11848"#;
11849    match run_powershell(ps_events) {
11850        Ok(o) if !o.trim().is_empty() => {
11851            let count = o.lines().filter(|l| !l.trim().is_empty()).count();
11852            out.push_str(&format!("- {count} recent logon failure(s) detected:\n"));
11853            for line in o.lines().take(max_entries) {
11854                let l = line.trim();
11855                if !l.is_empty() {
11856                    out.push_str(&format!("  {l}\n"));
11857                }
11858            }
11859        }
11860        _ => out.push_str("- No sign-in failures in the last 24h (or insufficient privileges to read Security log)\n"),
11861    }
11862
11863    // Credential providers
11864    out.push_str("\n=== Active credential providers ===\n");
11865    let ps_cp = r#"
11866Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers' -ErrorAction SilentlyContinue |
11867ForEach-Object {
11868    $name = (Get-ItemProperty $_.PSPath -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
11869    if ($name) { $name }
11870} | Select-Object -First 15
11871"#;
11872    match run_powershell(ps_cp) {
11873        Ok(o) if !o.trim().is_empty() => {
11874            for line in o.lines().take(max_entries) {
11875                let l = line.trim();
11876                if !l.is_empty() {
11877                    out.push_str(&format!("- {l}\n"));
11878                }
11879            }
11880        }
11881        _ => out.push_str("- Could not enumerate credential providers\n"),
11882    }
11883
11884    let mut findings: Vec<String> = Vec::new();
11885    if out.contains("WbioSrvc | Status: Stopped") {
11886        findings.push("Windows Biometric Service is stopped — Windows Hello face/fingerprint will not work until it is running.".into());
11887    }
11888    if out.contains("recent logon failure") && !out.contains("0 recent") {
11889        findings.push("Recent sign-in failures detected — check the Security event log for account lockout or credential issues.".into());
11890    }
11891
11892    let mut result = String::from("Host inspection: sign_in\n\n=== Findings ===\n");
11893    if findings.is_empty() {
11894        result.push_str("- No obvious sign-in or Windows Hello service failure detected.\n");
11895        result.push_str("  If Hello is prompting for PIN or won't recognize you, try Settings > Accounts > Sign-in options > Repair.\n");
11896    } else {
11897        for f in &findings {
11898            result.push_str(&format!("- Finding: {f}\n"));
11899        }
11900    }
11901    result.push('\n');
11902    result.push_str(&out);
11903    Ok(result)
11904}
11905
11906#[cfg(not(windows))]
11907fn inspect_sign_in(_max_entries: usize) -> Result<String, String> {
11908    Ok("Host inspection: sign_in\nSign-in / Windows Hello inspection is Windows-only.".into())
11909}
11910
11911// ── inspect_search_index ──────────────────────────────────────────────────────
11912
11913#[cfg(windows)]
11914fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
11915    let mut out = String::from("=== Windows Search service ===\n");
11916
11917    // Service state
11918    let ps_svc = r#"
11919$svc = Get-Service WSearch -ErrorAction SilentlyContinue
11920if ($svc) { "WSearch | Status: $($svc.Status) | StartType: $($svc.StartType)" }
11921else { "WSearch service not found" }
11922"#;
11923    match run_powershell(ps_svc) {
11924        Ok(o) => out.push_str(&format!("- {}\n", o.trim())),
11925        Err(_) => out.push_str("- Could not query WSearch service\n"),
11926    }
11927
11928    // Indexer state via registry
11929    out.push_str("\n=== Indexer state ===\n");
11930    let ps_idx = r#"
11931$key = 'HKLM:\SOFTWARE\Microsoft\Windows Search'
11932$props = Get-ItemProperty $key -ErrorAction SilentlyContinue
11933if ($props) {
11934    "SetupCompletedSuccessfully: $($props.SetupCompletedSuccessfully)"
11935    "IsContentIndexingEnabled: $($props.IsContentIndexingEnabled)"
11936    "DataDirectory: $($props.DataDirectory)"
11937} else { "Registry key not found" }
11938"#;
11939    match run_powershell(ps_idx) {
11940        Ok(o) => {
11941            for line in o.lines() {
11942                let l = line.trim();
11943                if !l.is_empty() {
11944                    out.push_str(&format!("- {l}\n"));
11945                }
11946            }
11947        }
11948        Err(_) => out.push_str("- Could not read indexer registry\n"),
11949    }
11950
11951    // Indexed locations
11952    out.push_str("\n=== Indexed locations ===\n");
11953    let ps_locs = r#"
11954$comObj = New-Object -ComObject Microsoft.Search.Administration.CSearchManager -ErrorAction SilentlyContinue
11955if ($comObj) {
11956    $catalog = $comObj.GetCatalog('SystemIndex')
11957    $manager = $catalog.GetCrawlScopeManager()
11958    $rules = $manager.EnumerateRoots()
11959    while ($true) {
11960        try {
11961            $root = $rules.Next(1)
11962            if ($root.Count -eq 0) { break }
11963            $r = $root[0]
11964            "  $($r.RootURL) | Default: $($r.IsDefault) | Included: $($r.IsIncluded)"
11965        } catch { break }
11966    }
11967} else { "  COM admin interface not available (normal on non-admin sessions)" }
11968"#;
11969    match run_powershell(ps_locs) {
11970        Ok(o) if !o.trim().is_empty() => {
11971            for line in o.lines() {
11972                let l = line.trim_end();
11973                if !l.is_empty() {
11974                    out.push_str(&format!("{l}\n"));
11975                }
11976            }
11977        }
11978        _ => {
11979            // Fallback: read from registry
11980            let ps_reg = r#"
11981Get-ChildItem 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search\Indexer\Sources' -ErrorAction SilentlyContinue |
11982ForEach-Object { "  $($_.PSChildName)" } | Select-Object -First 20
11983"#;
11984            match run_powershell(ps_reg) {
11985                Ok(o) if !o.trim().is_empty() => {
11986                    for line in o.lines() {
11987                        let l = line.trim_end();
11988                        if !l.is_empty() {
11989                            out.push_str(&format!("{l}\n"));
11990                        }
11991                    }
11992                }
11993                _ => out.push_str("  - Could not enumerate indexed locations\n"),
11994            }
11995        }
11996    }
11997
11998    // Recent indexing errors from event log
11999    out.push_str("\n=== Recent indexer errors (last 24h) ===\n");
12000    let ps_evts = r#"
12001Get-WinEvent -LogName 'Microsoft-Windows-Search/Operational' -MaxEvents 5 -ErrorAction SilentlyContinue |
12002Where-Object { $_.LevelDisplayName -eq 'Error' -or $_.LevelDisplayName -eq 'Warning' } |
12003ForEach-Object { "$($_.TimeCreated.ToString('HH:mm')) [$($_.LevelDisplayName)] $($_.Message.Substring(0, [Math]::Min(120, $_.Message.Length)))" }
12004"#;
12005    match run_powershell(ps_evts) {
12006        Ok(o) if !o.trim().is_empty() => {
12007            for line in o.lines() {
12008                let l = line.trim();
12009                if !l.is_empty() {
12010                    out.push_str(&format!("- {l}\n"));
12011                }
12012            }
12013        }
12014        _ => out.push_str("- No recent indexer errors found\n"),
12015    }
12016
12017    let mut findings: Vec<String> = Vec::new();
12018    if out.contains("Status: Stopped") {
12019        findings.push("Windows Search (WSearch) is stopped — search results will be slow or empty. Start the service: `Start-Service WSearch`.".into());
12020    }
12021    if out.contains("IsContentIndexingEnabled: 0")
12022        || out.contains("IsContentIndexingEnabled: False")
12023    {
12024        findings.push(
12025            "Content indexing is disabled — file content won't be searchable, only filenames."
12026                .into(),
12027        );
12028    }
12029    if out.contains("SetupCompletedSuccessfully: 0")
12030        || out.contains("SetupCompletedSuccessfully: False")
12031    {
12032        findings.push("Search indexer setup did not complete successfully — index may be corrupt. Rebuild: Settings > Search > Searching Windows > Advanced > Rebuild.".into());
12033    }
12034
12035    let mut result = String::from("Host inspection: search_index\n\n=== Findings ===\n");
12036    if findings.is_empty() {
12037        result.push_str("- Windows Search service and indexer appear healthy.\n");
12038        result.push_str("  If search still feels slow, the index may just be catching up — check indexing status in Settings > Search > Searching Windows.\n");
12039    } else {
12040        for f in &findings {
12041            result.push_str(&format!("- Finding: {f}\n"));
12042        }
12043    }
12044    result.push('\n');
12045    result.push_str(&out);
12046    Ok(result)
12047}
12048
12049#[cfg(not(windows))]
12050fn inspect_search_index(_max_entries: usize) -> Result<String, String> {
12051    Ok("Host inspection: search_index\nSearch index inspection is Windows-only.".into())
12052}