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        "services" => inspect_services(parse_name_filter(args), max_entries),
38        "processes" => inspect_processes(parse_name_filter(args), max_entries),
39        "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
40        "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
41        "disk" => {
42            let path = resolve_optional_path(args)?;
43            inspect_disk(path, max_entries).await
44        }
45        "ports" => inspect_ports(parse_port_filter(args), max_entries),
46        "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
47        "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
48        "health_report" | "system_health" => inspect_health_report(),
49        "storage" => inspect_storage(max_entries),
50        "hardware" => inspect_hardware(),
51        "updates" | "windows_update" => inspect_updates(),
52        "security" | "antivirus" | "defender" => inspect_security(),
53        "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
54        "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
55        "battery" => inspect_battery(),
56        "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
57        "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
58        "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
59        "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
60        "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
61        "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
62        "vpn" => inspect_vpn(),
63        "proxy" | "proxy_settings" => inspect_proxy(),
64        "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
65        "traceroute" | "tracert" | "trace_route" | "trace" => {
66            let host = args
67                .get("host")
68                .and_then(|v| v.as_str())
69                .unwrap_or("8.8.8.8")
70                .to_string();
71            inspect_traceroute(&host, max_entries)
72        }
73        "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
74        "arp" | "arp_table" => inspect_arp(),
75        "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
76        "os_config" | "system_config" => inspect_os_config(),
77        "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
78        "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
79        "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
80        "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
81        "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
82        "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
83        "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
84        "git_config" | "git_global" => inspect_git_config(),
85        "databases" | "database" | "db_services" | "db" => inspect_databases(),
86        "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
87        "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
88        "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
89        "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
90        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
91        "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
92        "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
93        "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
94        "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
95        "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
96        "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
97        "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
98        "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
99        "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
100        "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
101        "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
102        "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
103        "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
104        "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
105        "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
106        "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
107        "repo_doctor" => {
108            let path = resolve_optional_path(args)?;
109            inspect_repo_doctor(path, max_entries)
110        }
111        "directory" => {
112            let raw_path = args
113                .get("path")
114                .and_then(|v| v.as_str())
115                .ok_or_else(|| {
116                    "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
117                        .to_string()
118                })?;
119            let resolved = resolve_path(raw_path)?;
120            inspect_directory("Directory", resolved, max_entries).await
121        }
122        "disk_benchmark" | "stress_test" | "io_intensity" => {
123            let path = resolve_optional_path(args)?;
124            inspect_disk_benchmark(path).await
125        }
126        "permissions" | "acl" | "access_control" => {
127            let path = resolve_optional_path(args)?;
128            inspect_permissions(path, max_entries)
129        }
130        "login_history" | "logon_history" | "user_logins" => {
131            inspect_login_history(max_entries)
132        }
133        "share_access" | "unc_access" | "remote_share" => {
134            let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
135            inspect_share_access(path)
136        }
137        "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
138        "thermal" | "throttling" | "overheating" => inspect_thermal(),
139        "activation" | "license_status" | "slmgr" => inspect_activation(),
140        "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
141        "ad_user" | "ad" | "domain_user" => {
142            let identity = parse_name_filter(args).unwrap_or_default();
143            inspect_ad_user(&identity)
144        }
145        "dns_lookup" | "dig" | "nslookup" => {
146            let name = parse_name_filter(args).unwrap_or_default();
147            let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("SRV");
148            inspect_dns_lookup(&name, record_type)
149        }
150        "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
151        "ip_config" | "ip_detail" | "dhcp" => inspect_ip_config(),
152        "overclocker" | "thermal_deep" | "clocks" | "voltage" => inspect_overclocker().await,
153        other => Err(format!(
154            "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, 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, wsl, 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.",
155            other
156        )),
157
158    }
159}
160
161fn parse_max_entries(args: &Value) -> usize {
162    args.get("max_entries")
163        .and_then(|v| v.as_u64())
164        .map(|n| n as usize)
165        .unwrap_or(DEFAULT_MAX_ENTRIES)
166        .clamp(1, MAX_ENTRIES_CAP)
167}
168
169fn parse_port_filter(args: &Value) -> Option<u16> {
170    args.get("port")
171        .and_then(|v| v.as_u64())
172        .and_then(|n| u16::try_from(n).ok())
173}
174
175fn parse_name_filter(args: &Value) -> Option<String> {
176    args.get("name")
177        .and_then(|v| v.as_str())
178        .map(str::trim)
179        .filter(|value| !value.is_empty())
180        .map(|value| value.to_string())
181}
182
183fn parse_lookback_hours(args: &Value) -> Option<u32> {
184    args.get("lookback_hours")
185        .and_then(|v| v.as_u64())
186        .map(|n| n as u32)
187}
188
189fn parse_issue_text(args: &Value) -> Option<String> {
190    args.get("issue")
191        .and_then(|v| v.as_str())
192        .map(str::trim)
193        .filter(|value| !value.is_empty())
194        .map(|value| value.to_string())
195}
196
197fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
198    match args.get("path").and_then(|v| v.as_str()) {
199        Some(raw_path) => resolve_path(raw_path),
200        None => {
201            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
202        }
203    }
204}
205
206fn inspect_summary(max_entries: usize) -> Result<String, String> {
207    let current_dir =
208        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
209    let workspace_root = crate::tools::file_ops::workspace_root();
210    let workspace_mode = workspace_mode_label(&workspace_root);
211    let path_stats = analyze_path_env();
212    let toolchains = collect_toolchains();
213
214    let mut out = String::from("Host inspection: summary\n\n");
215    out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
216    out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
217    out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
218    out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
219    out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
220    out.push_str(&format!(
221        "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
222        path_stats.total_entries,
223        path_stats.unique_entries,
224        path_stats.duplicate_entries.len(),
225        path_stats.missing_entries.len()
226    ));
227
228    if toolchains.found.is_empty() {
229        out.push_str(
230            "- Toolchains found: none of the common developer tools were detected on PATH\n",
231        );
232    } else {
233        out.push_str("- Toolchains found:\n");
234        for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
235            out.push_str(&format!("  - {}: {}\n", label, version));
236        }
237        if toolchains.found.len() > max_entries.min(8) {
238            out.push_str(&format!(
239                "  - ... {} more found tools omitted\n",
240                toolchains.found.len() - max_entries.min(8)
241            ));
242        }
243    }
244
245    if !toolchains.missing.is_empty() {
246        out.push_str(&format!(
247            "- Common tools not detected on PATH: {}\n",
248            toolchains.missing.join(", ")
249        ));
250    }
251
252    for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
253        match path {
254            Some(path) if path.exists() => match count_top_level_items(&path) {
255                Ok(count) => out.push_str(&format!(
256                    "- {}: {} top-level items at {}\n",
257                    label,
258                    count,
259                    path.display()
260                )),
261                Err(e) => out.push_str(&format!(
262                    "- {}: exists at {} but could not inspect ({})\n",
263                    label,
264                    path.display(),
265                    e
266                )),
267            },
268            Some(path) => out.push_str(&format!(
269                "- {}: expected at {} but not found\n",
270                label,
271                path.display()
272            )),
273            None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
274        }
275    }
276
277    Ok(out.trim_end().to_string())
278}
279
280fn inspect_toolchains() -> Result<String, String> {
281    let report = collect_toolchains();
282    let mut out = String::from("Host inspection: toolchains\n\n");
283
284    if report.found.is_empty() {
285        out.push_str("- No common developer tools were detected on PATH.");
286    } else {
287        out.push_str("Detected developer tools:\n");
288        for (label, version) in report.found {
289            out.push_str(&format!("- {}: {}\n", label, version));
290        }
291    }
292
293    if !report.missing.is_empty() {
294        out.push_str("\nNot detected on PATH:\n");
295        for label in report.missing {
296            out.push_str(&format!("- {}\n", label));
297        }
298    }
299
300    Ok(out.trim_end().to_string())
301}
302
303fn inspect_path(max_entries: usize) -> Result<String, String> {
304    let path_stats = analyze_path_env();
305    let mut out = String::from("Host inspection: PATH\n\n");
306    out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
307    out.push_str(&format!(
308        "- Unique entries: {}\n",
309        path_stats.unique_entries
310    ));
311    out.push_str(&format!(
312        "- Duplicate entries: {}\n",
313        path_stats.duplicate_entries.len()
314    ));
315    out.push_str(&format!(
316        "- Missing paths: {}\n",
317        path_stats.missing_entries.len()
318    ));
319
320    out.push_str("\nPATH entries:\n");
321    for entry in path_stats.entries.iter().take(max_entries) {
322        out.push_str(&format!("- {}\n", entry));
323    }
324    if path_stats.entries.len() > max_entries {
325        out.push_str(&format!(
326            "- ... {} more entries omitted\n",
327            path_stats.entries.len() - max_entries
328        ));
329    }
330
331    if !path_stats.duplicate_entries.is_empty() {
332        out.push_str("\nDuplicate entries:\n");
333        for entry in path_stats.duplicate_entries.iter().take(max_entries) {
334            out.push_str(&format!("- {}\n", entry));
335        }
336        if path_stats.duplicate_entries.len() > max_entries {
337            out.push_str(&format!(
338                "- ... {} more duplicates omitted\n",
339                path_stats.duplicate_entries.len() - max_entries
340            ));
341        }
342    }
343
344    if !path_stats.missing_entries.is_empty() {
345        out.push_str("\nMissing directories:\n");
346        for entry in path_stats.missing_entries.iter().take(max_entries) {
347            out.push_str(&format!("- {}\n", entry));
348        }
349        if path_stats.missing_entries.len() > max_entries {
350            out.push_str(&format!(
351                "- ... {} more missing entries omitted\n",
352                path_stats.missing_entries.len() - max_entries
353            ));
354        }
355    }
356
357    Ok(out.trim_end().to_string())
358}
359
360fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
361    let path_stats = analyze_path_env();
362    let toolchains = collect_toolchains();
363    let package_managers = collect_package_managers();
364    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
365
366    let mut out = String::from("Host inspection: env_doctor\n\n");
367    out.push_str(&format!(
368        "- PATH health: {} duplicates, {} missing entries\n",
369        path_stats.duplicate_entries.len(),
370        path_stats.missing_entries.len()
371    ));
372    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
373    out.push_str(&format!(
374        "- Package managers found: {}\n",
375        package_managers.found.len()
376    ));
377
378    if !package_managers.found.is_empty() {
379        out.push_str("\nPackage managers:\n");
380        for (label, version) in package_managers.found.iter().take(max_entries) {
381            out.push_str(&format!("- {}: {}\n", label, version));
382        }
383        if package_managers.found.len() > max_entries {
384            out.push_str(&format!(
385                "- ... {} more package managers omitted\n",
386                package_managers.found.len() - max_entries
387            ));
388        }
389    }
390
391    if !path_stats.duplicate_entries.is_empty() {
392        out.push_str("\nDuplicate PATH entries:\n");
393        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
394            out.push_str(&format!("- {}\n", entry));
395        }
396        if path_stats.duplicate_entries.len() > max_entries.min(5) {
397            out.push_str(&format!(
398                "- ... {} more duplicate entries omitted\n",
399                path_stats.duplicate_entries.len() - max_entries.min(5)
400            ));
401        }
402    }
403
404    if !path_stats.missing_entries.is_empty() {
405        out.push_str("\nMissing PATH entries:\n");
406        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
407            out.push_str(&format!("- {}\n", entry));
408        }
409        if path_stats.missing_entries.len() > max_entries.min(5) {
410            out.push_str(&format!(
411                "- ... {} more missing entries omitted\n",
412                path_stats.missing_entries.len() - max_entries.min(5)
413            ));
414        }
415    }
416
417    if !findings.is_empty() {
418        out.push_str("\nFindings:\n");
419        for finding in findings.iter().take(max_entries.max(5)) {
420            out.push_str(&format!("- {}\n", finding));
421        }
422        if findings.len() > max_entries.max(5) {
423            out.push_str(&format!(
424                "- ... {} more findings omitted\n",
425                findings.len() - max_entries.max(5)
426            ));
427        }
428    } else {
429        out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
430    }
431
432    out.push_str(
433        "\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.",
434    );
435
436    Ok(out.trim_end().to_string())
437}
438
439#[derive(Clone, Copy, Debug, Eq, PartialEq)]
440enum FixPlanKind {
441    EnvPath,
442    PortConflict,
443    LmStudio,
444    DriverInstall,
445    GroupPolicy,
446    FirewallRule,
447    SshKey,
448    WslSetup,
449    ServiceConfig,
450    WindowsActivation,
451    RegistryEdit,
452    ScheduledTaskCreate,
453    DiskCleanup,
454    DnsResolution,
455    Generic,
456}
457
458async fn inspect_fix_plan(
459    issue: Option<String>,
460    port_filter: Option<u16>,
461    max_entries: usize,
462) -> Result<String, String> {
463    let issue = issue.unwrap_or_else(|| {
464        "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
465            .to_string()
466    });
467    let plan_kind = classify_fix_plan_kind(&issue, port_filter);
468    match plan_kind {
469        FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
470        FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
471        FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
472        FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
473        FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
474        FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
475        FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
476        FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
477        FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
478        FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
479        FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
480        FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
481        FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
482        FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
483        FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
484    }
485}
486
487fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
488    let lower = issue.to_ascii_lowercase();
489    // FirewallRule must be checked before PortConflict — "open port 80 in the firewall"
490    // is firewall rule creation, not a port ownership conflict.
491    if lower.contains("firewall rule")
492        || lower.contains("inbound rule")
493        || lower.contains("outbound rule")
494        || (lower.contains("firewall")
495            && (lower.contains("allow")
496                || lower.contains("block")
497                || lower.contains("create")
498                || lower.contains("open")))
499    {
500        FixPlanKind::FirewallRule
501    } else if port_filter.is_some()
502        || lower.contains("port ")
503        || lower.contains("address already in use")
504        || lower.contains("already in use")
505        || lower.contains("what owns port")
506        || lower.contains("listening on port")
507    {
508        FixPlanKind::PortConflict
509    } else if lower.contains("lm studio")
510        || lower.contains("localhost:1234")
511        || lower.contains("/v1/models")
512        || lower.contains("no coding model loaded")
513        || lower.contains("embedding model")
514        || lower.contains("server on port 1234")
515        || lower.contains("runtime refresh")
516    {
517        FixPlanKind::LmStudio
518    } else if lower.contains("driver")
519        || lower.contains("gpu driver")
520        || lower.contains("nvidia driver")
521        || lower.contains("amd driver")
522        || lower.contains("install driver")
523        || lower.contains("update driver")
524    {
525        FixPlanKind::DriverInstall
526    } else if lower.contains("group policy")
527        || lower.contains("gpedit")
528        || lower.contains("local policy")
529        || lower.contains("secpol")
530        || lower.contains("administrative template")
531    {
532        FixPlanKind::GroupPolicy
533    } else if lower.contains("ssh key")
534        || lower.contains("ssh-keygen")
535        || lower.contains("generate ssh")
536        || lower.contains("authorized_keys")
537        || lower.contains("id_rsa")
538        || lower.contains("id_ed25519")
539    {
540        FixPlanKind::SshKey
541    } else if lower.contains("wsl")
542        || lower.contains("windows subsystem for linux")
543        || lower.contains("install ubuntu")
544        || lower.contains("install linux on windows")
545        || lower.contains("wsl2")
546    {
547        FixPlanKind::WslSetup
548    } else if lower.contains("service")
549        && (lower.contains("start ")
550            || lower.contains("stop ")
551            || lower.contains("restart ")
552            || lower.contains("enable ")
553            || lower.contains("disable ")
554            || lower.contains("configure service"))
555    {
556        FixPlanKind::ServiceConfig
557    } else if lower.contains("activate windows")
558        || lower.contains("windows activation")
559        || lower.contains("product key")
560        || lower.contains("kms")
561        || lower.contains("not activated")
562    {
563        FixPlanKind::WindowsActivation
564    } else if lower.contains("registry")
565        || lower.contains("regedit")
566        || lower.contains("hklm")
567        || lower.contains("hkcu")
568        || lower.contains("reg add")
569        || lower.contains("reg delete")
570        || lower.contains("registry key")
571    {
572        FixPlanKind::RegistryEdit
573    } else if lower.contains("scheduled task")
574        || lower.contains("task scheduler")
575        || lower.contains("schtasks")
576        || lower.contains("create task")
577        || lower.contains("run on startup")
578        || lower.contains("run on schedule")
579        || lower.contains("cron")
580    {
581        FixPlanKind::ScheduledTaskCreate
582    } else if lower.contains("disk cleanup")
583        || lower.contains("free up disk")
584        || lower.contains("free up space")
585        || lower.contains("clear cache")
586        || lower.contains("disk full")
587        || lower.contains("low disk space")
588        || lower.contains("reclaim space")
589    {
590        FixPlanKind::DiskCleanup
591    } else if lower.contains("cargo")
592        || lower.contains("rustc")
593        || lower.contains("path")
594        || lower.contains("package manager")
595        || lower.contains("package managers")
596        || lower.contains("toolchain")
597        || lower.contains("winget")
598        || lower.contains("choco")
599        || lower.contains("scoop")
600        || lower.contains("python")
601        || lower.contains("node")
602    {
603        FixPlanKind::EnvPath
604    } else if lower.contains("dns ")
605        || lower.contains("nameserver")
606        || lower.contains("cannot resolve")
607        || lower.contains("nslookup")
608        || lower.contains("flushdns")
609    {
610        FixPlanKind::DnsResolution
611    } else {
612        FixPlanKind::Generic
613    }
614}
615
616fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
617    let path_stats = analyze_path_env();
618    let toolchains = collect_toolchains();
619    let package_managers = collect_package_managers();
620    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
621    let found_tools = toolchains
622        .found
623        .iter()
624        .map(|(label, _)| label.as_str())
625        .collect::<HashSet<_>>();
626    let found_managers = package_managers
627        .found
628        .iter()
629        .map(|(label, _)| label.as_str())
630        .collect::<HashSet<_>>();
631
632    let mut out = String::from("Host inspection: fix_plan\n\n");
633    out.push_str(&format!("- Requested issue: {}\n", issue));
634    out.push_str("- Fix-plan type: environment/path\n");
635    out.push_str(&format!(
636        "- PATH health: {} duplicates, {} missing entries\n",
637        path_stats.duplicate_entries.len(),
638        path_stats.missing_entries.len()
639    ));
640    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
641    out.push_str(&format!(
642        "- Package managers found: {}\n",
643        package_managers.found.len()
644    ));
645
646    out.push_str("\nLikely causes:\n");
647    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
648        out.push_str(
649            "- 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",
650        );
651    }
652    if path_stats.duplicate_entries.is_empty()
653        && path_stats.missing_entries.is_empty()
654        && !findings.is_empty()
655    {
656        for finding in findings.iter().take(max_entries.max(4)) {
657            out.push_str(&format!("- {}\n", finding));
658        }
659    } else {
660        if !path_stats.duplicate_entries.is_empty() {
661            out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
662        }
663        if !path_stats.missing_entries.is_empty() {
664            out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
665        }
666    }
667    if found_tools.contains("node")
668        && !found_managers.contains("npm")
669        && !found_managers.contains("pnpm")
670    {
671        out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
672    }
673    if found_tools.contains("python")
674        && !found_managers.contains("pip")
675        && !found_managers.contains("uv")
676        && !found_managers.contains("pipx")
677    {
678        out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
679    }
680
681    out.push_str("\nFix plan:\n");
682    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");
683    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
684        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");
685    } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
686        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");
687    }
688    if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
689        out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
690    }
691    if found_tools.contains("node")
692        && !found_managers.contains("npm")
693        && !found_managers.contains("pnpm")
694    {
695        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");
696    }
697    if found_tools.contains("python")
698        && !found_managers.contains("pip")
699        && !found_managers.contains("uv")
700        && !found_managers.contains("pipx")
701    {
702        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");
703    }
704
705    if !path_stats.duplicate_entries.is_empty() {
706        out.push_str("\nExample duplicate PATH rows:\n");
707        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
708            out.push_str(&format!("- {}\n", entry));
709        }
710    }
711    if !path_stats.missing_entries.is_empty() {
712        out.push_str("\nExample missing PATH rows:\n");
713        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
714            out.push_str(&format!("- {}\n", entry));
715        }
716    }
717
718    out.push_str(
719        "\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.",
720    );
721    Ok(out.trim_end().to_string())
722}
723
724fn inspect_port_fix_plan(
725    issue: &str,
726    port_filter: Option<u16>,
727    max_entries: usize,
728) -> Result<String, String> {
729    let requested_port = port_filter.or_else(|| first_port_in_text(issue));
730    let listeners = collect_listening_ports().unwrap_or_default();
731    let mut matching = listeners;
732    if let Some(port) = requested_port {
733        matching.retain(|entry| entry.port == port);
734    }
735    let processes = collect_processes().unwrap_or_default();
736
737    let mut out = String::from("Host inspection: fix_plan\n\n");
738    out.push_str(&format!("- Requested issue: {}\n", issue));
739    out.push_str("- Fix-plan type: port_conflict\n");
740    if let Some(port) = requested_port {
741        out.push_str(&format!("- Requested port: {}\n", port));
742    } else {
743        out.push_str("- Requested port: not parsed from the issue text\n");
744    }
745    out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
746
747    if !matching.is_empty() {
748        out.push_str("\nCurrent listeners:\n");
749        for entry in matching.iter().take(max_entries.min(5)) {
750            let process_name = entry
751                .pid
752                .as_deref()
753                .and_then(|pid| pid.parse::<u32>().ok())
754                .and_then(|pid| {
755                    processes
756                        .iter()
757                        .find(|process| process.pid == pid)
758                        .map(|process| process.name.as_str())
759                })
760                .unwrap_or("unknown");
761            let pid = entry.pid.as_deref().unwrap_or("unknown");
762            out.push_str(&format!(
763                "- {} {} ({}) pid {} process {}\n",
764                entry.protocol, entry.local, entry.state, pid, process_name
765            ));
766        }
767    }
768
769    out.push_str("\nFix plan:\n");
770    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");
771    if !matching.is_empty() {
772        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");
773    } else {
774        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");
775    }
776    out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
777    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");
778    out.push_str(
779        "\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.",
780    );
781    Ok(out.trim_end().to_string())
782}
783
784async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
785    let config = crate::agent::config::load_config();
786    let configured_api = config
787        .api_url
788        .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
789    let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
790    let reachability = probe_http_endpoint(&models_url).await;
791    let embed_model = detect_loaded_embed_model(&configured_api).await;
792
793    let mut out = String::from("Host inspection: fix_plan\n\n");
794    out.push_str(&format!("- Requested issue: {}\n", issue));
795    out.push_str("- Fix-plan type: lm_studio\n");
796    out.push_str(&format!("- Configured API URL: {}\n", configured_api));
797    out.push_str(&format!("- Probe URL: {}\n", models_url));
798    match &reachability {
799        EndpointProbe::Reachable(status) => {
800            out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
801        }
802        EndpointProbe::Unreachable(detail) => {
803            out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
804        }
805    }
806    out.push_str(&format!(
807        "- Embedding model loaded: {}\n",
808        embed_model.as_deref().unwrap_or("none detected")
809    ));
810
811    out.push_str("\nFix plan:\n");
812    match reachability {
813        EndpointProbe::Reachable(_) => {
814            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");
815        }
816        EndpointProbe::Unreachable(_) => {
817            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");
818        }
819    }
820    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");
821    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");
822    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");
823    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");
824    if let Some(model) = embed_model {
825        out.push_str(&format!(
826            "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
827            model
828        ));
829    }
830    if max_entries > 0 {
831        out.push_str(
832            "\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.",
833        );
834    }
835    Ok(out.trim_end().to_string())
836}
837
838fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
839    // Read GPU info from the hardware topic output for grounding
840    #[cfg(target_os = "windows")]
841    let gpu_info = {
842        let out = Command::new("powershell")
843            .args([
844                "-NoProfile",
845                "-NonInteractive",
846                "-Command",
847                "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
848            ])
849            .output()
850            .ok()
851            .and_then(|o| String::from_utf8(o.stdout).ok())
852            .unwrap_or_default();
853        out.trim().to_string()
854    };
855    #[cfg(not(target_os = "windows"))]
856    let gpu_info = String::from("(GPU detection not available on this platform)");
857
858    let mut out = String::from("Host inspection: fix_plan\n\n");
859    out.push_str(&format!("- Requested issue: {}\n", issue));
860    out.push_str("- Fix-plan type: driver_install\n");
861    if !gpu_info.is_empty() {
862        out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
863    }
864    out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
865    out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
866    out.push_str(
867        "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
868    );
869    out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
870    out.push_str("4. Download the latest driver directly from the manufacturer:\n");
871    out.push_str("   - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
872    out.push_str("   - AMD: amd.com/support (use Auto-Detect tool)\n");
873    out.push_str("   - Intel: intel.com/content/www/us/en/download-center/home.html\n");
874    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");
875    out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
876    out.push_str("\nVerification:\n");
877    out.push_str("- After reboot, run in PowerShell:\n  Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
878    out.push_str("- The DriverVersion should match what you installed.\n");
879    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.");
880    Ok(out.trim_end().to_string())
881}
882
883fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
884    // Check Windows edition — Group Policy editor is not available on Home editions
885    #[cfg(target_os = "windows")]
886    let edition = {
887        Command::new("powershell")
888            .args([
889                "-NoProfile",
890                "-NonInteractive",
891                "-Command",
892                "(Get-CimInstance Win32_OperatingSystem).Caption",
893            ])
894            .output()
895            .ok()
896            .and_then(|o| String::from_utf8(o.stdout).ok())
897            .unwrap_or_default()
898            .trim()
899            .to_string()
900    };
901    #[cfg(not(target_os = "windows"))]
902    let edition = String::from("(Windows edition detection not available)");
903
904    let is_home = edition.to_lowercase().contains("home");
905
906    let mut out = String::from("Host inspection: fix_plan\n\n");
907    out.push_str(&format!("- Requested issue: {}\n", issue));
908    out.push_str("- Fix-plan type: group_policy\n");
909    out.push_str(&format!(
910        "- Windows edition detected: {}\n",
911        if edition.is_empty() {
912            "unknown".to_string()
913        } else {
914            edition.clone()
915        }
916    ));
917
918    if is_home {
919        out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
920        out.push_str("Options on Home edition:\n");
921        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");
922        out.push_str(
923            "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
924        );
925        out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
926    } else {
927        out.push_str("\nFix plan — Editing Local Group Policy:\n");
928        out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
929        out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
930        out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
931        out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
932        out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
933        out.push_str("6. To force immediate application, run in an elevated PowerShell:\n  gpupdate /force\n");
934    }
935    out.push_str("\nVerification:\n");
936    out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
937    out.push_str(
938        "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
939    );
940    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.");
941    Ok(out.trim_end().to_string())
942}
943
944fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
945    #[cfg(target_os = "windows")]
946    let profile_state = {
947        Command::new("powershell")
948            .args([
949                "-NoProfile",
950                "-NonInteractive",
951                "-Command",
952                "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
953            ])
954            .output()
955            .ok()
956            .and_then(|o| String::from_utf8(o.stdout).ok())
957            .unwrap_or_default()
958            .trim()
959            .to_string()
960    };
961    #[cfg(not(target_os = "windows"))]
962    let profile_state = String::new();
963
964    let mut out = String::from("Host inspection: fix_plan\n\n");
965    out.push_str(&format!("- Requested issue: {}\n", issue));
966    out.push_str("- Fix-plan type: firewall_rule\n");
967    if !profile_state.is_empty() {
968        out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
969    }
970    out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
971    out.push_str("\nTo ALLOW inbound traffic on a port:\n");
972    out.push_str("  New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
973    out.push_str("\nTo BLOCK outbound traffic to an address:\n");
974    out.push_str("  New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
975    out.push_str("\nTo ALLOW an application through the firewall:\n");
976    out.push_str("  New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
977    out.push_str("\nTo REMOVE a rule you created:\n");
978    out.push_str("  Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
979    out.push_str("\nTo see existing custom rules:\n");
980    out.push_str("  Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
981    out.push_str("\nVerification:\n");
982    out.push_str("- After creating the rule, test reachability from another machine or use:\n  Test-NetConnection -ComputerName localhost -Port 8080\n");
983    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.");
984    Ok(out.trim_end().to_string())
985}
986
987fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
988    let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
989    let ssh_dir = home.join(".ssh");
990    let has_ssh_dir = ssh_dir.exists();
991    let has_ed25519 = ssh_dir.join("id_ed25519").exists();
992    let has_rsa = ssh_dir.join("id_rsa").exists();
993    let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
994
995    let mut out = String::from("Host inspection: fix_plan\n\n");
996    out.push_str(&format!("- Requested issue: {}\n", issue));
997    out.push_str("- Fix-plan type: ssh_key\n");
998    out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
999    out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
1000    out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
1001    out.push_str(&format!(
1002        "- authorized_keys found: {}\n",
1003        has_authorized_keys
1004    ));
1005
1006    if has_ed25519 {
1007        out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1008    }
1009
1010    out.push_str("\nFix plan — Generating an SSH key pair:\n");
1011    out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1012    out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1013    out.push_str("   ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1014    out.push_str(
1015        "   - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1016    );
1017    out.push_str("   - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1018    out.push_str("3. Start the SSH agent and add your key:\n");
1019    out.push_str("   # Windows (PowerShell, run as Admin once to enable the service):\n");
1020    out.push_str("   Set-Service -Name ssh-agent -StartupType Automatic\n");
1021    out.push_str("   Start-Service ssh-agent\n");
1022    out.push_str("   # Then add the key (normal PowerShell):\n");
1023    out.push_str("   ssh-add ~/.ssh/id_ed25519\n");
1024    out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1025    out.push_str("   # Print your public key:\n");
1026    out.push_str("   cat ~/.ssh/id_ed25519.pub\n");
1027    out.push_str("   # On the target server, append it:\n");
1028    out.push_str("   echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1029    out.push_str("   chmod 600 ~/.ssh/authorized_keys\n");
1030    out.push_str("5. Test the connection:\n");
1031    out.push_str("   ssh user@server-address\n");
1032    out.push_str("\nFor GitHub/GitLab:\n");
1033    out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1034    out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1035    out.push_str("- Test: ssh -T git@github.com\n");
1036    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.");
1037    Ok(out.trim_end().to_string())
1038}
1039
1040fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1041    #[cfg(target_os = "windows")]
1042    let wsl_status = {
1043        let out = Command::new("wsl")
1044            .args(["--status"])
1045            .output()
1046            .ok()
1047            .and_then(|o| {
1048                let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1049                let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1050                Some(format!("{}{}", stdout, stderr))
1051            })
1052            .unwrap_or_default();
1053        out.trim().to_string()
1054    };
1055    #[cfg(not(target_os = "windows"))]
1056    let wsl_status = String::new();
1057
1058    let wsl_installed =
1059        !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1060
1061    let mut out = String::from("Host inspection: fix_plan\n\n");
1062    out.push_str(&format!("- Requested issue: {}\n", issue));
1063    out.push_str("- Fix-plan type: wsl_setup\n");
1064    out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1065    if !wsl_status.is_empty() {
1066        out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1067    }
1068
1069    if wsl_installed {
1070        out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1071        out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1072        out.push_str("   Available distros: wsl --list --online\n");
1073        out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1074        out.push_str("3. Create your Linux username and password when prompted.\n");
1075    } else {
1076        out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1077        out.push_str("1. Open PowerShell as Administrator.\n");
1078        out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1079        out.push_str("   wsl --install\n");
1080        out.push_str("   (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1081        out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1082        out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1083        out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1084        out.push_str("   wsl --set-default-version 2\n");
1085        out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1086        out.push_str("   wsl --install -d Debian\n");
1087        out.push_str("   wsl --list --online   # to see all available distros\n");
1088    }
1089    out.push_str("\nVerification:\n");
1090    out.push_str("- Run: wsl --list --verbose\n");
1091    out.push_str("- You should see your distro with State: Running and Version: 2\n");
1092    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.");
1093    Ok(out.trim_end().to_string())
1094}
1095
1096fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1097    let lower = issue.to_ascii_lowercase();
1098    // Extract service name hints from the issue text
1099    let service_hint = if lower.contains("ssh") {
1100        Some("sshd")
1101    } else if lower.contains("mysql") {
1102        Some("MySQL80")
1103    } else if lower.contains("postgres") || lower.contains("postgresql") {
1104        Some("postgresql")
1105    } else if lower.contains("redis") {
1106        Some("Redis")
1107    } else if lower.contains("nginx") {
1108        Some("nginx")
1109    } else if lower.contains("apache") {
1110        Some("Apache2.4")
1111    } else {
1112        None
1113    };
1114
1115    #[cfg(target_os = "windows")]
1116    let service_state = if let Some(svc) = service_hint {
1117        Command::new("powershell")
1118            .args([
1119                "-NoProfile",
1120                "-NonInteractive",
1121                "-Command",
1122                &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1123            ])
1124            .output()
1125            .ok()
1126            .and_then(|o| String::from_utf8(o.stdout).ok())
1127            .unwrap_or_default()
1128            .trim()
1129            .to_string()
1130    } else {
1131        String::new()
1132    };
1133    #[cfg(not(target_os = "windows"))]
1134    let service_state = String::new();
1135
1136    let mut out = String::from("Host inspection: fix_plan\n\n");
1137    out.push_str(&format!("- Requested issue: {}\n", issue));
1138    out.push_str("- Fix-plan type: service_config\n");
1139    if let Some(svc) = service_hint {
1140        out.push_str(&format!("- Service detected in request: {}\n", svc));
1141    }
1142    if !service_state.is_empty() {
1143        out.push_str(&format!("- Current state: {}\n", service_state));
1144    }
1145
1146    out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1147    out.push_str("\nStart a service:\n");
1148    out.push_str("  Start-Service -Name \"ServiceName\"\n");
1149    out.push_str("\nStop a service:\n");
1150    out.push_str("  Stop-Service -Name \"ServiceName\"\n");
1151    out.push_str("\nRestart a service:\n");
1152    out.push_str("  Restart-Service -Name \"ServiceName\"\n");
1153    out.push_str("\nEnable a service to start automatically:\n");
1154    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1155    out.push_str("\nDisable a service (stops it from auto-starting):\n");
1156    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1157    out.push_str("\nFind the exact service name:\n");
1158    out.push_str("  Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1159    out.push_str("\nVerification:\n");
1160    out.push_str("  Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1161    if let Some(svc) = service_hint {
1162        out.push_str(&format!(
1163            "\nFor your detected service ({}):\n  Get-Service -Name '{}'\n",
1164            svc, svc
1165        ));
1166    }
1167    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.");
1168    Ok(out.trim_end().to_string())
1169}
1170
1171fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1172    #[cfg(target_os = "windows")]
1173    let activation_status = {
1174        Command::new("powershell")
1175            .args([
1176                "-NoProfile",
1177                "-NonInteractive",
1178                "-Command",
1179                "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 + ')' })\" }",
1180            ])
1181            .output()
1182            .ok()
1183            .and_then(|o| String::from_utf8(o.stdout).ok())
1184            .unwrap_or_default()
1185            .trim()
1186            .to_string()
1187    };
1188    #[cfg(not(target_os = "windows"))]
1189    let activation_status = String::new();
1190
1191    let is_licensed = activation_status.to_lowercase().contains("licensed")
1192        && !activation_status.to_lowercase().contains("not licensed");
1193
1194    let mut out = String::from("Host inspection: fix_plan\n\n");
1195    out.push_str(&format!("- Requested issue: {}\n", issue));
1196    out.push_str("- Fix-plan type: windows_activation\n");
1197    if !activation_status.is_empty() {
1198        out.push_str(&format!(
1199            "- Current activation state:\n{}\n",
1200            activation_status
1201        ));
1202    }
1203
1204    if is_licensed {
1205        out.push_str(
1206            "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1207        );
1208        out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1209        out.push_str("   (Forces an online activation attempt)\n");
1210        out.push_str("2. Check activation details: slmgr /dli\n");
1211    } else {
1212        out.push_str("\nFix plan — Activating Windows:\n");
1213        out.push_str("1. Check your current status first:\n");
1214        out.push_str("   slmgr /dli   (basic info)\n");
1215        out.push_str("   slmgr /dlv   (detailed — shows remaining rearms, grace period)\n");
1216        out.push_str("\n2. If you have a retail product key:\n");
1217        out.push_str("   slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX   (install key)\n");
1218        out.push_str("   slmgr /ato                                   (activate online)\n");
1219        out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1220        out.push_str("   - Go to Settings → System → Activation\n");
1221        out.push_str("   - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1222        out.push_str("   - Sign in with the Microsoft account that holds the license\n");
1223        out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1224        out.push_str("   - Contact your IT department for the KMS server address\n");
1225        out.push_str("   - Set KMS host: slmgr /skms kms.yourorg.com\n");
1226        out.push_str("   - Activate:    slmgr /ato\n");
1227    }
1228    out.push_str("\nVerification:\n");
1229    out.push_str("  slmgr /dli   — should show 'License Status: Licensed'\n");
1230    out.push_str("  Or: Settings → System → Activation → 'Windows is activated'\n");
1231    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.");
1232    Ok(out.trim_end().to_string())
1233}
1234
1235fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1236    let mut out = String::from("Host inspection: fix_plan\n\n");
1237    out.push_str(&format!("- Requested issue: {}\n", issue));
1238    out.push_str("- Fix-plan type: registry_edit\n");
1239    out.push_str(
1240        "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1241    );
1242    out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1243    out.push_str("\n1. Back up before you touch anything:\n");
1244    out.push_str("   # Export the key you're about to change (PowerShell):\n");
1245    out.push_str("   reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1246    out.push_str("   # Or export the whole registry (takes a while):\n");
1247    out.push_str("   reg export HKLM C:\\backup\\HKLM_full.reg\n");
1248    out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1249    out.push_str("   Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1250    out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1251    out.push_str(
1252        "   Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1253    );
1254    out.push_str("\n4. Create a new key:\n");
1255    out.push_str("   New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1256    out.push_str("\n5. Delete a value:\n");
1257    out.push_str("   Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1258    out.push_str("\n6. Restore from backup if something breaks:\n");
1259    out.push_str("   reg import C:\\backup\\MyKey_backup.reg\n");
1260    out.push_str("\nCommon registry hives:\n");
1261    out.push_str("  HKLM = HKEY_LOCAL_MACHINE  (machine-wide, requires Admin)\n");
1262    out.push_str("  HKCU = HKEY_CURRENT_USER   (current user, no elevation needed)\n");
1263    out.push_str("  HKCR = HKEY_CLASSES_ROOT    (file associations)\n");
1264    out.push_str("\nVerification:\n");
1265    out.push_str("  Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1266    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.");
1267    Ok(out.trim_end().to_string())
1268}
1269
1270fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1271    let mut out = String::from("Host inspection: fix_plan\n\n");
1272    out.push_str(&format!("- Requested issue: {}\n", issue));
1273    out.push_str("- Fix-plan type: scheduled_task_create\n");
1274    out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1275    out.push_str("\nExample: Run a script at 9 AM every day\n");
1276    out.push_str("  $action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1277    out.push_str("  $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1278    out.push_str("  Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1279    out.push_str("\nExample: Run at Windows startup\n");
1280    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1281    out.push_str("  Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1282    out.push_str("\nExample: Run at user logon\n");
1283    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1284    out.push_str(
1285        "  Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1286    );
1287    out.push_str("\nExample: Run every 30 minutes\n");
1288    out.push_str("  $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1289    out.push_str("\nView all tasks:\n");
1290    out.push_str("  Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1291    out.push_str("\nDelete a task:\n");
1292    out.push_str("  Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1293    out.push_str("\nRun a task immediately:\n");
1294    out.push_str("  Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1295    out.push_str("\nVerification:\n");
1296    out.push_str("  Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1297    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.");
1298    Ok(out.trim_end().to_string())
1299}
1300
1301fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1302    #[cfg(target_os = "windows")]
1303    let disk_info = {
1304        Command::new("powershell")
1305            .args([
1306                "-NoProfile",
1307                "-NonInteractive",
1308                "-Command",
1309                "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\" }",
1310            ])
1311            .output()
1312            .ok()
1313            .and_then(|o| String::from_utf8(o.stdout).ok())
1314            .unwrap_or_default()
1315            .trim()
1316            .to_string()
1317    };
1318    #[cfg(not(target_os = "windows"))]
1319    let disk_info = String::new();
1320
1321    let mut out = String::from("Host inspection: fix_plan\n\n");
1322    out.push_str(&format!("- Requested issue: {}\n", issue));
1323    out.push_str("- Fix-plan type: disk_cleanup\n");
1324    if !disk_info.is_empty() {
1325        out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1326    }
1327    out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1328    out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1329    out.push_str("   cleanmgr /sageset:1    (configure what to clean)\n");
1330    out.push_str("   cleanmgr /sagerun:1    (run the cleanup)\n");
1331    out.push_str("   Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1332    out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1333    out.push_str("   Stop-Service wuauserv\n");
1334    out.push_str("   Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1335    out.push_str("   Start-Service wuauserv\n");
1336    out.push_str("\n3. Clear Windows Temp folder:\n");
1337    out.push_str("   Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1338    out.push_str(
1339        "   Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1340    );
1341    out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1342    out.push_str("   - Rust build artifacts: cargo clean  (inside each project)\n");
1343    out.push_str("   - npm cache:  npm cache clean --force\n");
1344    out.push_str("   - pip cache:  pip cache purge\n");
1345    out.push_str(
1346        "   - Docker:     docker system prune -a  (removes all unused images/containers)\n",
1347    );
1348    out.push_str("   - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force  (will redownload on next build)\n");
1349    out.push_str("\n5. Check for large files:\n");
1350    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");
1351    out.push_str("\nVerification:\n");
1352    out.push_str(
1353        "  Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1354    );
1355    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.");
1356    Ok(out.trim_end().to_string())
1357}
1358
1359fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1360    let mut out = String::from("Host inspection: fix_plan\n\n");
1361    out.push_str(&format!("- Requested issue: {}\n", issue));
1362    out.push_str("- Fix-plan type: generic\n");
1363    out.push_str(
1364        "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1365         Structured lanes available:\n\
1366         - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1367         - Port conflict (address already in use, what owns port)\n\
1368         - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1369         - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1370         - Group Policy (gpedit, local policy, administrative template)\n\
1371         - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1372         - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1373         - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1374         - Service config (start/stop/restart/enable/disable a service)\n\
1375         - Windows activation (product key, not activated, kms)\n\
1376         - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1377         - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1378         - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1379         - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1380    );
1381    Ok(out.trim_end().to_string())
1382}
1383
1384fn inspect_resource_load() -> Result<String, String> {
1385    #[cfg(target_os = "windows")]
1386    {
1387        let output = Command::new("powershell")
1388            .args([
1389                "-NoProfile",
1390                "-Command",
1391                "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1392            ])
1393            .output()
1394            .map_err(|e| format!("Failed to run powershell: {e}"))?;
1395
1396        let text = String::from_utf8_lossy(&output.stdout);
1397        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1398
1399        let cpu_load = lines
1400            .next()
1401            .and_then(|l| l.parse::<u32>().ok())
1402            .unwrap_or(0);
1403        let mem_json = lines.collect::<Vec<_>>().join("");
1404        let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1405
1406        let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1407        let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1408        let used_kb = total_kb.saturating_sub(free_kb);
1409        let mem_percent = if total_kb > 0 {
1410            (used_kb * 100) / total_kb
1411        } else {
1412            0
1413        };
1414
1415        let mut out = String::from("Host inspection: resource_load\n\n");
1416        out.push_str("**System Performance Summary:**\n");
1417        out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1418        out.push_str(&format!(
1419            "- Memory Usage: {} / {} ({}%)\n",
1420            human_bytes(used_kb * 1024),
1421            human_bytes(total_kb * 1024),
1422            mem_percent
1423        ));
1424
1425        if cpu_load > 85 {
1426            out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1427        }
1428        if mem_percent > 90 {
1429            out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1430        }
1431
1432        Ok(out)
1433    }
1434    #[cfg(not(target_os = "windows"))]
1435    {
1436        Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1437    }
1438}
1439
1440#[derive(Debug)]
1441enum EndpointProbe {
1442    Reachable(u16),
1443    Unreachable(String),
1444}
1445
1446async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1447    let client = match reqwest::Client::builder()
1448        .timeout(std::time::Duration::from_secs(3))
1449        .build()
1450    {
1451        Ok(client) => client,
1452        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1453    };
1454
1455    match client.get(url).send().await {
1456        Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1457        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1458    }
1459}
1460
1461async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1462    let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1463    let url = format!("{}/api/v0/models", base);
1464    let client = reqwest::Client::builder()
1465        .timeout(std::time::Duration::from_secs(3))
1466        .build()
1467        .ok()?;
1468
1469    #[derive(serde::Deserialize)]
1470    struct ModelList {
1471        data: Vec<ModelEntry>,
1472    }
1473    #[derive(serde::Deserialize)]
1474    struct ModelEntry {
1475        id: String,
1476        #[serde(rename = "type", default)]
1477        model_type: String,
1478        #[serde(default)]
1479        state: String,
1480    }
1481
1482    let response = client.get(url).send().await.ok()?;
1483    let models = response.json::<ModelList>().await.ok()?;
1484    models
1485        .data
1486        .into_iter()
1487        .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1488        .map(|model| model.id)
1489}
1490
1491fn first_port_in_text(text: &str) -> Option<u16> {
1492    text.split(|c: char| !c.is_ascii_digit())
1493        .find(|fragment| !fragment.is_empty())
1494        .and_then(|fragment| fragment.parse::<u16>().ok())
1495}
1496
1497fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1498    let mut processes = collect_processes()?;
1499    if let Some(filter) = name_filter.as_deref() {
1500        let lowered = filter.to_ascii_lowercase();
1501        processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1502    }
1503    processes.sort_by(|a, b| {
1504        let a_cpu = a.cpu_percent.unwrap_or(0.0);
1505        let b_cpu = b.cpu_percent.unwrap_or(0.0);
1506        b_cpu
1507            .partial_cmp(&a_cpu)
1508            .unwrap_or(std::cmp::Ordering::Equal)
1509            .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1510            .then_with(|| a.name.cmp(&b.name))
1511            .then_with(|| a.pid.cmp(&b.pid))
1512    });
1513
1514    let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1515
1516    let mut out = String::from("Host inspection: processes\n\n");
1517    if let Some(filter) = name_filter.as_deref() {
1518        out.push_str(&format!("- Filter name: {}\n", filter));
1519    }
1520    out.push_str(&format!("- Processes found: {}\n", processes.len()));
1521    out.push_str(&format!(
1522        "- Total reported working set: {}\n",
1523        human_bytes(total_memory)
1524    ));
1525
1526    if processes.is_empty() {
1527        out.push_str("\nNo running processes matched.");
1528        return Ok(out);
1529    }
1530
1531    out.push_str("\nTop processes by resource usage:\n");
1532    for entry in processes.iter().take(max_entries) {
1533        let cpu_str = entry
1534            .cpu_percent
1535            .map(|p| format!(" [CPU: {:.1}%]", p))
1536            .or_else(|| entry.cpu_seconds.map(|s| format!(" [CPU: {:.1}s]", s)))
1537            .unwrap_or_default();
1538        let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1539            format!(" [I/O R:{}/W:{}]", r, w)
1540        } else {
1541            " [I/O unknown]".to_string()
1542        };
1543        out.push_str(&format!(
1544            "- {} (pid {}) - {}{}{}{}\n",
1545            entry.name,
1546            entry.pid,
1547            human_bytes(entry.memory_bytes),
1548            cpu_str,
1549            io_str,
1550            entry
1551                .detail
1552                .as_deref()
1553                .map(|detail| format!(" [{}]", detail))
1554                .unwrap_or_default()
1555        ));
1556    }
1557    if processes.len() > max_entries {
1558        out.push_str(&format!(
1559            "- ... {} more processes omitted\n",
1560            processes.len() - max_entries
1561        ));
1562    }
1563
1564    Ok(out.trim_end().to_string())
1565}
1566
1567fn inspect_network(max_entries: usize) -> Result<String, String> {
1568    let adapters = collect_network_adapters()?;
1569    let active_count = adapters
1570        .iter()
1571        .filter(|adapter| adapter.is_active())
1572        .count();
1573    let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1574
1575    let mut out = String::from("Host inspection: network\n\n");
1576    out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1577    out.push_str(&format!("- Active adapters: {}\n", active_count));
1578    out.push_str(&format!(
1579        "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1580        exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1581    ));
1582
1583    if adapters.is_empty() {
1584        out.push_str("\nNo adapter details were detected.");
1585        return Ok(out);
1586    }
1587
1588    out.push_str("\nAdapter summary:\n");
1589    for adapter in adapters.iter().take(max_entries) {
1590        let status = if adapter.is_active() {
1591            "active"
1592        } else if adapter.disconnected {
1593            "disconnected"
1594        } else {
1595            "idle"
1596        };
1597        let mut details = vec![status.to_string()];
1598        if !adapter.ipv4.is_empty() {
1599            details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1600        }
1601        if !adapter.ipv6.is_empty() {
1602            details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1603        }
1604        if !adapter.gateways.is_empty() {
1605            details.push(format!("gateway {}", adapter.gateways.join(", ")));
1606        }
1607        if !adapter.dns_servers.is_empty() {
1608            details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1609        }
1610        out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1611    }
1612    if adapters.len() > max_entries {
1613        out.push_str(&format!(
1614            "- ... {} more adapters omitted\n",
1615            adapters.len() - max_entries
1616        ));
1617    }
1618
1619    Ok(out.trim_end().to_string())
1620}
1621
1622fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1623    let mut services = collect_services()?;
1624    if let Some(filter) = name_filter.as_deref() {
1625        let lowered = filter.to_ascii_lowercase();
1626        services.retain(|entry| {
1627            entry.name.to_ascii_lowercase().contains(&lowered)
1628                || entry
1629                    .display_name
1630                    .as_deref()
1631                    .map(|d| d.to_ascii_lowercase().contains(&lowered))
1632                    .unwrap_or(false)
1633        });
1634    }
1635
1636    services.sort_by(|a, b| {
1637        let a_running =
1638            a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
1639        let b_running =
1640            b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
1641        b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
1642    });
1643
1644    let running = services
1645        .iter()
1646        .filter(|entry| {
1647            entry.status.eq_ignore_ascii_case("running")
1648                || entry.status.eq_ignore_ascii_case("active")
1649        })
1650        .count();
1651    let failed = services
1652        .iter()
1653        .filter(|entry| {
1654            entry.status.eq_ignore_ascii_case("failed")
1655                || entry.status.eq_ignore_ascii_case("error")
1656                || entry.status.eq_ignore_ascii_case("stopped")
1657        })
1658        .count();
1659
1660    let mut out = String::from("Host inspection: services\n\n");
1661    if let Some(filter) = name_filter.as_deref() {
1662        out.push_str(&format!("- Filter name: {}\n", filter));
1663    }
1664    out.push_str(&format!("- Services found: {}\n", services.len()));
1665    out.push_str(&format!("- Running/active: {}\n", running));
1666    out.push_str(&format!("- Failed/stopped: {}\n", failed));
1667
1668    if services.is_empty() {
1669        out.push_str("\nNo services matched.");
1670        return Ok(out);
1671    }
1672
1673    // Split into running and stopped sections so both are always visible.
1674    let per_section = (max_entries / 2).max(5);
1675
1676    let running_services: Vec<_> = services
1677        .iter()
1678        .filter(|e| {
1679            e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
1680        })
1681        .collect();
1682    let stopped_services: Vec<_> = services
1683        .iter()
1684        .filter(|e| {
1685            e.status.eq_ignore_ascii_case("stopped")
1686                || e.status.eq_ignore_ascii_case("failed")
1687                || e.status.eq_ignore_ascii_case("error")
1688        })
1689        .collect();
1690
1691    let fmt_entry = |entry: &&ServiceEntry| {
1692        let startup = entry
1693            .startup
1694            .as_deref()
1695            .map(|v| format!(" | startup {}", v))
1696            .unwrap_or_default();
1697        let logon = entry
1698            .start_name
1699            .as_deref()
1700            .map(|v| format!(" | LogOn: {}", v))
1701            .unwrap_or_default();
1702        let display = entry
1703            .display_name
1704            .as_deref()
1705            .filter(|v| *v != &entry.name)
1706            .map(|v| format!(" [{}]", v))
1707            .unwrap_or_default();
1708        format!(
1709            "- {}{} - {}{}{}\n",
1710            entry.name, display, entry.status, startup, logon
1711        )
1712    };
1713
1714    out.push_str(&format!(
1715        "\nRunning services ({} total, showing up to {}):\n",
1716        running_services.len(),
1717        per_section
1718    ));
1719    for entry in running_services.iter().take(per_section) {
1720        out.push_str(&fmt_entry(entry));
1721    }
1722    if running_services.len() > per_section {
1723        out.push_str(&format!(
1724            "- ... {} more running services omitted\n",
1725            running_services.len() - per_section
1726        ));
1727    }
1728
1729    out.push_str(&format!(
1730        "\nStopped/failed services ({} total, showing up to {}):\n",
1731        stopped_services.len(),
1732        per_section
1733    ));
1734    for entry in stopped_services.iter().take(per_section) {
1735        out.push_str(&fmt_entry(entry));
1736    }
1737    if stopped_services.len() > per_section {
1738        out.push_str(&format!(
1739            "- ... {} more stopped services omitted\n",
1740            stopped_services.len() - per_section
1741        ));
1742    }
1743
1744    Ok(out.trim_end().to_string())
1745}
1746
1747async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
1748    inspect_directory("Disk", path, max_entries).await
1749}
1750
1751fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
1752    let mut listeners = collect_listening_ports()?;
1753    if let Some(port) = port_filter {
1754        listeners.retain(|entry| entry.port == port);
1755    }
1756    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
1757
1758    let mut out = String::from("Host inspection: ports\n\n");
1759    if let Some(port) = port_filter {
1760        out.push_str(&format!("- Filter port: {}\n", port));
1761    }
1762    out.push_str(&format!(
1763        "- Listening endpoints found: {}\n",
1764        listeners.len()
1765    ));
1766
1767    if listeners.is_empty() {
1768        out.push_str("\nNo listening endpoints matched.");
1769        return Ok(out);
1770    }
1771
1772    out.push_str("\nListening endpoints:\n");
1773    for entry in listeners.iter().take(max_entries) {
1774        let pid_str = entry
1775            .pid
1776            .as_deref()
1777            .map(|p| format!(" pid {}", p))
1778            .unwrap_or_default();
1779        let name_str = entry
1780            .process_name
1781            .as_deref()
1782            .map(|n| format!(" [{}]", n))
1783            .unwrap_or_default();
1784        out.push_str(&format!(
1785            "- {} {} ({}){}{}\n",
1786            entry.protocol, entry.local, entry.state, pid_str, name_str
1787        ));
1788    }
1789    if listeners.len() > max_entries {
1790        out.push_str(&format!(
1791            "- ... {} more listening endpoints omitted\n",
1792            listeners.len() - max_entries
1793        ));
1794    }
1795
1796    Ok(out.trim_end().to_string())
1797}
1798
1799fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
1800    if !path.exists() {
1801        return Err(format!("Path does not exist: {}", path.display()));
1802    }
1803    if !path.is_dir() {
1804        return Err(format!("Path is not a directory: {}", path.display()));
1805    }
1806
1807    let markers = collect_project_markers(&path);
1808    let hematite_state = collect_hematite_state(&path);
1809    let git_state = inspect_git_state(&path);
1810    let release_state = inspect_release_artifacts(&path);
1811
1812    let mut out = String::from("Host inspection: repo_doctor\n\n");
1813    out.push_str(&format!("- Path: {}\n", path.display()));
1814    out.push_str(&format!(
1815        "- Workspace mode: {}\n",
1816        workspace_mode_for_path(&path)
1817    ));
1818
1819    if markers.is_empty() {
1820        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");
1821    } else {
1822        out.push_str("- Project markers:\n");
1823        for marker in markers.iter().take(max_entries) {
1824            out.push_str(&format!("  - {}\n", marker));
1825        }
1826    }
1827
1828    match git_state {
1829        Some(git) => {
1830            out.push_str(&format!("- Git root: {}\n", git.root.display()));
1831            out.push_str(&format!("- Git branch: {}\n", git.branch));
1832            out.push_str(&format!("- Git status: {}\n", git.status_label()));
1833        }
1834        None => out.push_str("- Git: not inside a detected work tree\n"),
1835    }
1836
1837    out.push_str(&format!(
1838        "- Hematite docs/imports/reports: {}/{}/{}\n",
1839        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
1840    ));
1841    if hematite_state.workspace_profile {
1842        out.push_str("- Workspace profile: present\n");
1843    } else {
1844        out.push_str("- Workspace profile: absent\n");
1845    }
1846
1847    if let Some(release) = release_state {
1848        out.push_str(&format!("- Cargo version: {}\n", release.version));
1849        out.push_str(&format!(
1850            "- Windows artifacts for current version: {}/{}/{}\n",
1851            bool_label(release.portable_dir),
1852            bool_label(release.portable_zip),
1853            bool_label(release.setup_exe)
1854        ));
1855    }
1856
1857    Ok(out.trim_end().to_string())
1858}
1859
1860async fn inspect_known_directory(
1861    label: &str,
1862    path: Option<PathBuf>,
1863    max_entries: usize,
1864) -> Result<String, String> {
1865    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
1866    inspect_directory(label, path, max_entries).await
1867}
1868
1869async fn inspect_directory(
1870    label: &str,
1871    path: PathBuf,
1872    max_entries: usize,
1873) -> Result<String, String> {
1874    let label = label.to_string();
1875    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
1876        .await
1877        .map_err(|e| format!("inspect_host task failed: {e}"))?
1878}
1879
1880fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
1881    if !path.exists() {
1882        return Err(format!("Path does not exist: {}", path.display()));
1883    }
1884    if !path.is_dir() {
1885        return Err(format!("Path is not a directory: {}", path.display()));
1886    }
1887
1888    let mut top_level_entries = Vec::new();
1889    for entry in fs::read_dir(path)
1890        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
1891    {
1892        match entry {
1893            Ok(entry) => top_level_entries.push(entry),
1894            Err(_) => continue,
1895        }
1896    }
1897    top_level_entries.sort_by_key(|entry| entry.file_name());
1898
1899    let top_level_count = top_level_entries.len();
1900    let mut sample_names = Vec::new();
1901    let mut largest_entries = Vec::new();
1902    let mut aggregate = PathAggregate::default();
1903    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
1904
1905    for entry in top_level_entries {
1906        let name = entry.file_name().to_string_lossy().to_string();
1907        if sample_names.len() < max_entries {
1908            sample_names.push(name.clone());
1909        }
1910        let kind = match entry.file_type() {
1911            Ok(ft) if ft.is_dir() => "dir",
1912            Ok(ft) if ft.is_symlink() => "symlink",
1913            _ => "file",
1914        };
1915        let stats = measure_path(&entry.path(), &mut budget);
1916        aggregate.merge(&stats);
1917        largest_entries.push(LargestEntry {
1918            name,
1919            kind,
1920            bytes: stats.total_bytes,
1921        });
1922    }
1923
1924    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
1925
1926    let mut out = format!("Directory inspection: {}\n\n", label);
1927    out.push_str(&format!("- Path: {}\n", path.display()));
1928    out.push_str(&format!("- Top-level items: {}\n", top_level_count));
1929    out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
1930    out.push_str(&format!(
1931        "- Recursive directories: {}\n",
1932        aggregate.dir_count
1933    ));
1934    out.push_str(&format!(
1935        "- Total size: {}{}\n",
1936        human_bytes(aggregate.total_bytes),
1937        if aggregate.partial {
1938            " (partial scan)"
1939        } else {
1940            ""
1941        }
1942    ));
1943    if aggregate.skipped_entries > 0 {
1944        out.push_str(&format!(
1945            "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
1946            aggregate.skipped_entries
1947        ));
1948    }
1949
1950    if !largest_entries.is_empty() {
1951        out.push_str("\nLargest top-level entries:\n");
1952        for entry in largest_entries.iter().take(max_entries) {
1953            out.push_str(&format!(
1954                "- {} [{}] - {}\n",
1955                entry.name,
1956                entry.kind,
1957                human_bytes(entry.bytes)
1958            ));
1959        }
1960    }
1961
1962    if !sample_names.is_empty() {
1963        out.push_str("\nSample names:\n");
1964        for name in sample_names {
1965            out.push_str(&format!("- {}\n", name));
1966        }
1967    }
1968
1969    Ok(out.trim_end().to_string())
1970}
1971
1972fn resolve_path(raw: &str) -> Result<PathBuf, String> {
1973    let trimmed = raw.trim();
1974    if trimmed.is_empty() {
1975        return Err("Path must not be empty.".to_string());
1976    }
1977
1978    if let Some(rest) = trimmed
1979        .strip_prefix("~/")
1980        .or_else(|| trimmed.strip_prefix("~\\"))
1981    {
1982        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
1983        return Ok(home.join(rest));
1984    }
1985
1986    let path = PathBuf::from(trimmed);
1987    if path.is_absolute() {
1988        Ok(path)
1989    } else {
1990        let cwd =
1991            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
1992        let full_path = cwd.join(&path);
1993
1994        // Heuristic: If it's a relative path to .hematite or hematite.exe and doesn't exist here,
1995        // check the user's home directory.
1996        if !full_path.exists()
1997            && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
1998        {
1999            if let Some(home) = home::home_dir() {
2000                let home_path = home.join(trimmed);
2001                if home_path.exists() {
2002                    return Ok(home_path);
2003                }
2004            }
2005        }
2006
2007        Ok(full_path)
2008    }
2009}
2010
2011fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2012    workspace_mode_for_path(workspace_root)
2013}
2014
2015fn workspace_mode_for_path(path: &Path) -> &'static str {
2016    if is_project_marker_path(path) {
2017        "project"
2018    } else if path.join(".hematite").join("docs").exists()
2019        || path.join(".hematite").join("imports").exists()
2020        || path.join(".hematite").join("reports").exists()
2021    {
2022        "docs-only"
2023    } else {
2024        "general directory"
2025    }
2026}
2027
2028fn is_project_marker_path(path: &Path) -> bool {
2029    [
2030        "Cargo.toml",
2031        "package.json",
2032        "pyproject.toml",
2033        "go.mod",
2034        "composer.json",
2035        "requirements.txt",
2036        "Makefile",
2037        "justfile",
2038    ]
2039    .iter()
2040    .any(|name| path.join(name).exists())
2041        || path.join(".git").exists()
2042}
2043
2044fn preferred_shell_label() -> &'static str {
2045    #[cfg(target_os = "windows")]
2046    {
2047        "PowerShell"
2048    }
2049    #[cfg(not(target_os = "windows"))]
2050    {
2051        "sh"
2052    }
2053}
2054
2055fn desktop_dir() -> Option<PathBuf> {
2056    home::home_dir().map(|home| home.join("Desktop"))
2057}
2058
2059fn downloads_dir() -> Option<PathBuf> {
2060    home::home_dir().map(|home| home.join("Downloads"))
2061}
2062
2063fn count_top_level_items(path: &Path) -> Result<usize, String> {
2064    let mut count = 0usize;
2065    for entry in
2066        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2067    {
2068        if entry.is_ok() {
2069            count += 1;
2070        }
2071    }
2072    Ok(count)
2073}
2074
2075#[derive(Default)]
2076struct PathAggregate {
2077    total_bytes: u64,
2078    file_count: u64,
2079    dir_count: u64,
2080    skipped_entries: u64,
2081    partial: bool,
2082}
2083
2084impl PathAggregate {
2085    fn merge(&mut self, other: &PathAggregate) {
2086        self.total_bytes += other.total_bytes;
2087        self.file_count += other.file_count;
2088        self.dir_count += other.dir_count;
2089        self.skipped_entries += other.skipped_entries;
2090        self.partial |= other.partial;
2091    }
2092}
2093
2094struct LargestEntry {
2095    name: String,
2096    kind: &'static str,
2097    bytes: u64,
2098}
2099
2100fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2101    if *budget == 0 {
2102        return PathAggregate {
2103            partial: true,
2104            skipped_entries: 1,
2105            ..PathAggregate::default()
2106        };
2107    }
2108    *budget -= 1;
2109
2110    let metadata = match fs::symlink_metadata(path) {
2111        Ok(metadata) => metadata,
2112        Err(_) => {
2113            return PathAggregate {
2114                skipped_entries: 1,
2115                ..PathAggregate::default()
2116            }
2117        }
2118    };
2119
2120    let file_type = metadata.file_type();
2121    if file_type.is_symlink() {
2122        return PathAggregate {
2123            skipped_entries: 1,
2124            ..PathAggregate::default()
2125        };
2126    }
2127
2128    if metadata.is_file() {
2129        return PathAggregate {
2130            total_bytes: metadata.len(),
2131            file_count: 1,
2132            ..PathAggregate::default()
2133        };
2134    }
2135
2136    if !metadata.is_dir() {
2137        return PathAggregate::default();
2138    }
2139
2140    let mut aggregate = PathAggregate {
2141        dir_count: 1,
2142        ..PathAggregate::default()
2143    };
2144
2145    let read_dir = match fs::read_dir(path) {
2146        Ok(read_dir) => read_dir,
2147        Err(_) => {
2148            aggregate.skipped_entries += 1;
2149            return aggregate;
2150        }
2151    };
2152
2153    for child in read_dir {
2154        match child {
2155            Ok(child) => {
2156                let child_stats = measure_path(&child.path(), budget);
2157                aggregate.merge(&child_stats);
2158            }
2159            Err(_) => aggregate.skipped_entries += 1,
2160        }
2161    }
2162
2163    aggregate
2164}
2165
2166struct PathAnalysis {
2167    total_entries: usize,
2168    unique_entries: usize,
2169    entries: Vec<String>,
2170    duplicate_entries: Vec<String>,
2171    missing_entries: Vec<String>,
2172}
2173
2174fn analyze_path_env() -> PathAnalysis {
2175    let mut entries = Vec::new();
2176    let mut duplicate_entries = Vec::new();
2177    let mut missing_entries = Vec::new();
2178    let mut seen = HashSet::new();
2179
2180    let raw_path = std::env::var_os("PATH").unwrap_or_default();
2181    for path in std::env::split_paths(&raw_path) {
2182        let display = path.display().to_string();
2183        if display.trim().is_empty() {
2184            continue;
2185        }
2186
2187        let normalized = normalize_path_entry(&display);
2188        if !seen.insert(normalized) {
2189            duplicate_entries.push(display.clone());
2190        }
2191        if !path.exists() {
2192            missing_entries.push(display.clone());
2193        }
2194        entries.push(display);
2195    }
2196
2197    let total_entries = entries.len();
2198    let unique_entries = seen.len();
2199
2200    PathAnalysis {
2201        total_entries,
2202        unique_entries,
2203        entries,
2204        duplicate_entries,
2205        missing_entries,
2206    }
2207}
2208
2209fn normalize_path_entry(value: &str) -> String {
2210    #[cfg(target_os = "windows")]
2211    {
2212        value
2213            .replace('/', "\\")
2214            .trim_end_matches(['\\', '/'])
2215            .to_ascii_lowercase()
2216    }
2217    #[cfg(not(target_os = "windows"))]
2218    {
2219        value.trim_end_matches('/').to_string()
2220    }
2221}
2222
2223struct ToolchainReport {
2224    found: Vec<(String, String)>,
2225    missing: Vec<String>,
2226}
2227
2228struct PackageManagerReport {
2229    found: Vec<(String, String)>,
2230}
2231
2232#[derive(Debug, Clone)]
2233struct ProcessEntry {
2234    name: String,
2235    pid: u32,
2236    memory_bytes: u64,
2237    cpu_seconds: Option<f64>,
2238    cpu_percent: Option<f64>,
2239    read_ops: Option<u64>,
2240    write_ops: Option<u64>,
2241    detail: Option<String>,
2242}
2243
2244#[derive(Debug, Clone)]
2245struct ServiceEntry {
2246    name: String,
2247    status: String,
2248    startup: Option<String>,
2249    display_name: Option<String>,
2250    start_name: Option<String>,
2251}
2252
2253#[derive(Debug, Clone, Default)]
2254struct NetworkAdapter {
2255    name: String,
2256    ipv4: Vec<String>,
2257    ipv6: Vec<String>,
2258    gateways: Vec<String>,
2259    dns_servers: Vec<String>,
2260    disconnected: bool,
2261}
2262
2263impl NetworkAdapter {
2264    fn is_active(&self) -> bool {
2265        !self.disconnected
2266            && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2267    }
2268}
2269
2270#[derive(Debug, Clone, Copy, Default)]
2271struct ListenerExposureSummary {
2272    loopback_only: usize,
2273    wildcard_public: usize,
2274    specific_bind: usize,
2275}
2276
2277#[derive(Debug, Clone)]
2278struct ListeningPort {
2279    protocol: String,
2280    local: String,
2281    port: u16,
2282    state: String,
2283    pid: Option<String>,
2284    process_name: Option<String>,
2285}
2286
2287fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2288    #[cfg(target_os = "windows")]
2289    {
2290        collect_windows_listening_ports()
2291    }
2292    #[cfg(not(target_os = "windows"))]
2293    {
2294        collect_unix_listening_ports()
2295    }
2296}
2297
2298fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2299    #[cfg(target_os = "windows")]
2300    {
2301        collect_windows_network_adapters()
2302    }
2303    #[cfg(not(target_os = "windows"))]
2304    {
2305        collect_unix_network_adapters()
2306    }
2307}
2308
2309fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2310    #[cfg(target_os = "windows")]
2311    {
2312        collect_windows_services()
2313    }
2314    #[cfg(not(target_os = "windows"))]
2315    {
2316        collect_unix_services()
2317    }
2318}
2319
2320#[cfg(target_os = "windows")]
2321fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2322    let output = Command::new("netstat")
2323        .args(["-ano", "-p", "tcp"])
2324        .output()
2325        .map_err(|e| format!("Failed to run netstat: {e}"))?;
2326    if !output.status.success() {
2327        return Err("netstat returned a non-success status.".to_string());
2328    }
2329
2330    let text = String::from_utf8_lossy(&output.stdout);
2331    let mut listeners = Vec::new();
2332    for line in text.lines() {
2333        let trimmed = line.trim();
2334        if !trimmed.starts_with("TCP") {
2335            continue;
2336        }
2337        let cols: Vec<&str> = trimmed.split_whitespace().collect();
2338        if cols.len() < 5 || cols[3] != "LISTENING" {
2339            continue;
2340        }
2341        let Some(port) = extract_port_from_socket(cols[1]) else {
2342            continue;
2343        };
2344        listeners.push(ListeningPort {
2345            protocol: cols[0].to_string(),
2346            local: cols[1].to_string(),
2347            port,
2348            state: cols[3].to_string(),
2349            pid: Some(cols[4].to_string()),
2350            process_name: None,
2351        });
2352    }
2353
2354    // Enrich with process names via PowerShell — works without elevation for
2355    // most user-space processes. System processes (PID 4, etc.) stay unnamed.
2356    let unique_pids: Vec<String> = listeners
2357        .iter()
2358        .filter_map(|l| l.pid.clone())
2359        .collect::<HashSet<_>>()
2360        .into_iter()
2361        .collect();
2362
2363    if !unique_pids.is_empty() {
2364        let pid_list = unique_pids.join(",");
2365        let ps_cmd = format!(
2366            "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2367            pid_list
2368        );
2369        if let Ok(ps_out) = Command::new("powershell")
2370            .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2371            .output()
2372        {
2373            let mut pid_map = std::collections::HashMap::<String, String>::new();
2374            let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2375            for line in ps_text.lines() {
2376                let parts: Vec<&str> = line.split_whitespace().collect();
2377                if parts.len() >= 2 {
2378                    pid_map.insert(parts[0].to_string(), parts[1].to_string());
2379                }
2380            }
2381            for listener in &mut listeners {
2382                if let Some(pid) = &listener.pid {
2383                    listener.process_name = pid_map.get(pid).cloned();
2384                }
2385            }
2386        }
2387    }
2388
2389    Ok(listeners)
2390}
2391
2392#[cfg(not(target_os = "windows"))]
2393fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2394    let output = Command::new("ss")
2395        .args(["-ltn"])
2396        .output()
2397        .map_err(|e| format!("Failed to run ss: {e}"))?;
2398    if !output.status.success() {
2399        return Err("ss returned a non-success status.".to_string());
2400    }
2401
2402    let text = String::from_utf8_lossy(&output.stdout);
2403    let mut listeners = Vec::new();
2404    for line in text.lines().skip(1) {
2405        let cols: Vec<&str> = line.split_whitespace().collect();
2406        if cols.len() < 4 {
2407            continue;
2408        }
2409        let Some(port) = extract_port_from_socket(cols[3]) else {
2410            continue;
2411        };
2412        listeners.push(ListeningPort {
2413            protocol: "tcp".to_string(),
2414            local: cols[3].to_string(),
2415            port,
2416            state: cols[0].to_string(),
2417            pid: None,
2418            process_name: None,
2419        });
2420    }
2421
2422    Ok(listeners)
2423}
2424
2425fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
2426    #[cfg(target_os = "windows")]
2427    {
2428        collect_windows_processes()
2429    }
2430    #[cfg(not(target_os = "windows"))]
2431    {
2432        collect_unix_processes()
2433    }
2434}
2435
2436#[cfg(target_os = "windows")]
2437fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
2438    let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
2439    let output = Command::new("powershell")
2440        .args(["-NoProfile", "-Command", command])
2441        .output()
2442        .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
2443    if !output.status.success() {
2444        return Err("PowerShell service inspection returned a non-success status.".to_string());
2445    }
2446
2447    parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
2448}
2449
2450#[cfg(not(target_os = "windows"))]
2451fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
2452    let status_output = Command::new("systemctl")
2453        .args([
2454            "list-units",
2455            "--type=service",
2456            "--all",
2457            "--no-pager",
2458            "--no-legend",
2459            "--plain",
2460        ])
2461        .output()
2462        .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
2463    if !status_output.status.success() {
2464        return Err("systemctl list-units returned a non-success status.".to_string());
2465    }
2466
2467    let startup_output = Command::new("systemctl")
2468        .args([
2469            "list-unit-files",
2470            "--type=service",
2471            "--no-legend",
2472            "--no-pager",
2473            "--plain",
2474        ])
2475        .output()
2476        .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
2477    if !startup_output.status.success() {
2478        return Err("systemctl list-unit-files returned a non-success status.".to_string());
2479    }
2480
2481    Ok(parse_unix_services(
2482        &String::from_utf8_lossy(&status_output.stdout),
2483        &String::from_utf8_lossy(&startup_output.stdout),
2484    ))
2485}
2486
2487#[cfg(target_os = "windows")]
2488fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2489    let output = Command::new("ipconfig")
2490        .args(["/all"])
2491        .output()
2492        .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
2493    if !output.status.success() {
2494        return Err("ipconfig returned a non-success status.".to_string());
2495    }
2496
2497    Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
2498        &output.stdout,
2499    )))
2500}
2501
2502#[cfg(not(target_os = "windows"))]
2503fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2504    let addr_output = Command::new("ip")
2505        .args(["-o", "addr", "show", "up"])
2506        .output()
2507        .map_err(|e| format!("Failed to run ip addr: {e}"))?;
2508    if !addr_output.status.success() {
2509        return Err("ip addr returned a non-success status.".to_string());
2510    }
2511
2512    let route_output = Command::new("ip")
2513        .args(["route", "show", "default"])
2514        .output()
2515        .map_err(|e| format!("Failed to run ip route: {e}"))?;
2516    if !route_output.status.success() {
2517        return Err("ip route returned a non-success status.".to_string());
2518    }
2519
2520    let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
2521    apply_unix_default_routes(
2522        &mut adapters,
2523        &String::from_utf8_lossy(&route_output.stdout),
2524    );
2525    apply_unix_dns_servers(&mut adapters);
2526    Ok(adapters)
2527}
2528
2529#[cfg(target_os = "windows")]
2530fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
2531    // We take two samples of CPU time separated by a short interval to calculate recent CPU %
2532    let script = r#"
2533        $s1 = Get-Process | Select-Object Id, CPU
2534        Start-Sleep -Milliseconds 250
2535        $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
2536        $s2 | ForEach-Object {
2537            $p2 = $_
2538            $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
2539            $pct = 0.0
2540            if ($p1 -and $p2.CPU -gt $p1.CPU) {
2541                # (Delta CPU seconds / interval) * 100 / LogicalProcessors
2542                # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
2543                # Standard Task Manager style is (delta / interval) * 100.
2544                $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
2545            }
2546            "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
2547        }
2548    "#;
2549
2550    let output = Command::new("powershell")
2551        .args(["-NoProfile", "-Command", script])
2552        .output()
2553        .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
2554
2555    let text = String::from_utf8_lossy(&output.stdout);
2556    let mut out = Vec::new();
2557    for line in text.lines() {
2558        let parts: Vec<&str> = line.trim().split('|').collect();
2559        if parts.len() < 5 {
2560            continue;
2561        }
2562        let mut entry = ProcessEntry {
2563            name: "unknown".to_string(),
2564            pid: 0,
2565            memory_bytes: 0,
2566            cpu_seconds: None,
2567            cpu_percent: None,
2568            read_ops: None,
2569            write_ops: None,
2570            detail: None,
2571        };
2572        for p in parts {
2573            if let Some((k, v)) = p.split_once(':') {
2574                match k {
2575                    "PID" => entry.pid = v.parse().unwrap_or(0),
2576                    "NAME" => entry.name = v.to_string(),
2577                    "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
2578                    "CPU_S" => entry.cpu_seconds = v.parse().ok(),
2579                    "CPU_P" => entry.cpu_percent = v.parse().ok(),
2580                    "READ" => entry.read_ops = v.parse().ok(),
2581                    "WRITE" => entry.write_ops = v.parse().ok(),
2582                    _ => {}
2583                }
2584            }
2585        }
2586        out.push(entry);
2587    }
2588    Ok(out)
2589}
2590
2591#[cfg(not(target_os = "windows"))]
2592fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
2593    let output = Command::new("ps")
2594        .args(["-eo", "pid=,rss=,comm="])
2595        .output()
2596        .map_err(|e| format!("Failed to run ps: {e}"))?;
2597    if !output.status.success() {
2598        return Err("ps returned a non-success status.".to_string());
2599    }
2600
2601    let text = String::from_utf8_lossy(&output.stdout);
2602    let mut processes = Vec::new();
2603    for line in text.lines() {
2604        let cols: Vec<&str> = line.split_whitespace().collect();
2605        if cols.len() < 3 {
2606            continue;
2607        }
2608        let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
2609        else {
2610            continue;
2611        };
2612        processes.push(ProcessEntry {
2613            name: cols[2..].join(" "),
2614            pid,
2615            memory_bytes: rss_kib * 1024,
2616            cpu_seconds: None,
2617            cpu_percent: None,
2618            read_ops: None,
2619            write_ops: None,
2620            detail: None,
2621        });
2622    }
2623
2624    Ok(processes)
2625}
2626
2627fn extract_port_from_socket(value: &str) -> Option<u16> {
2628    let cleaned = value.trim().trim_matches(['[', ']']);
2629    let port_str = cleaned.rsplit(':').next()?;
2630    port_str.parse::<u16>().ok()
2631}
2632
2633fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
2634    let mut summary = ListenerExposureSummary::default();
2635    for entry in listeners {
2636        let local = entry.local.to_ascii_lowercase();
2637        if is_loopback_listener(&local) {
2638            summary.loopback_only += 1;
2639        } else if is_wildcard_listener(&local) {
2640            summary.wildcard_public += 1;
2641        } else {
2642            summary.specific_bind += 1;
2643        }
2644    }
2645    summary
2646}
2647
2648fn is_loopback_listener(local: &str) -> bool {
2649    local.starts_with("127.")
2650        || local.starts_with("[::1]")
2651        || local.starts_with("::1")
2652        || local.starts_with("localhost:")
2653}
2654
2655fn is_wildcard_listener(local: &str) -> bool {
2656    local.starts_with("0.0.0.0:")
2657        || local.starts_with("[::]:")
2658        || local.starts_with(":::")
2659        || local == "*:*"
2660}
2661
2662struct GitState {
2663    root: PathBuf,
2664    branch: String,
2665    dirty_entries: usize,
2666}
2667
2668impl GitState {
2669    fn status_label(&self) -> String {
2670        if self.dirty_entries == 0 {
2671            "clean".to_string()
2672        } else {
2673            format!("dirty ({} changed path(s))", self.dirty_entries)
2674        }
2675    }
2676}
2677
2678fn inspect_git_state(path: &Path) -> Option<GitState> {
2679    let root = capture_first_line(
2680        "git",
2681        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
2682    )?;
2683    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
2684        .unwrap_or_else(|| "detached".to_string());
2685    let output = Command::new("git")
2686        .args(["-C", path.to_str()?, "status", "--short"])
2687        .output()
2688        .ok()?;
2689    if !output.status.success() {
2690        return None;
2691    }
2692    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
2693    Some(GitState {
2694        root: PathBuf::from(root),
2695        branch,
2696        dirty_entries,
2697    })
2698}
2699
2700struct HematiteState {
2701    docs_count: usize,
2702    import_count: usize,
2703    report_count: usize,
2704    workspace_profile: bool,
2705}
2706
2707fn collect_hematite_state(path: &Path) -> HematiteState {
2708    let root = path.join(".hematite");
2709    HematiteState {
2710        docs_count: count_entries_if_exists(&root.join("docs")),
2711        import_count: count_entries_if_exists(&root.join("imports")),
2712        report_count: count_entries_if_exists(&root.join("reports")),
2713        workspace_profile: root.join("workspace_profile.json").exists(),
2714    }
2715}
2716
2717fn count_entries_if_exists(path: &Path) -> usize {
2718    if !path.exists() || !path.is_dir() {
2719        return 0;
2720    }
2721    fs::read_dir(path)
2722        .ok()
2723        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
2724        .unwrap_or(0)
2725}
2726
2727fn collect_project_markers(path: &Path) -> Vec<String> {
2728    [
2729        "Cargo.toml",
2730        "package.json",
2731        "pyproject.toml",
2732        "go.mod",
2733        "justfile",
2734        "Makefile",
2735        ".git",
2736    ]
2737    .iter()
2738    .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
2739    .collect()
2740}
2741
2742struct ReleaseArtifactState {
2743    version: String,
2744    portable_dir: bool,
2745    portable_zip: bool,
2746    setup_exe: bool,
2747}
2748
2749fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
2750    let cargo_toml = path.join("Cargo.toml");
2751    if !cargo_toml.exists() {
2752        return None;
2753    }
2754    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
2755    let version = [regex_line_capture(
2756        &cargo_text,
2757        r#"(?m)^version\s*=\s*"([^"]+)""#,
2758    )?]
2759    .concat();
2760    let dist_windows = path.join("dist").join("windows");
2761    let prefix = format!("Hematite-{}", version);
2762    Some(ReleaseArtifactState {
2763        version,
2764        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
2765        portable_zip: dist_windows
2766            .join(format!("{}-portable.zip", prefix))
2767            .exists(),
2768        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
2769    })
2770}
2771
2772fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
2773    let regex = regex::Regex::new(pattern).ok()?;
2774    let captures = regex.captures(text)?;
2775    captures.get(1).map(|m| m.as_str().to_string())
2776}
2777
2778fn bool_label(value: bool) -> &'static str {
2779    if value {
2780        "yes"
2781    } else {
2782        "no"
2783    }
2784}
2785
2786fn collect_toolchains() -> ToolchainReport {
2787    let checks = [
2788        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
2789        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
2790        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
2791        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
2792        ToolCheck::new(
2793            "npm",
2794            &[
2795                CommandProbe::new("npm", &["--version"]),
2796                CommandProbe::new("npm.cmd", &["--version"]),
2797            ],
2798        ),
2799        ToolCheck::new(
2800            "pnpm",
2801            &[
2802                CommandProbe::new("pnpm", &["--version"]),
2803                CommandProbe::new("pnpm.cmd", &["--version"]),
2804            ],
2805        ),
2806        ToolCheck::new(
2807            "python",
2808            &[
2809                CommandProbe::new("python", &["--version"]),
2810                CommandProbe::new("python3", &["--version"]),
2811                CommandProbe::new("py", &["-3", "--version"]),
2812                CommandProbe::new("py", &["--version"]),
2813            ],
2814        ),
2815        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
2816        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
2817        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
2818        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
2819    ];
2820
2821    let mut found = Vec::new();
2822    let mut missing = Vec::new();
2823
2824    for check in checks {
2825        match check.detect() {
2826            Some(version) => found.push((check.label.to_string(), version)),
2827            None => missing.push(check.label.to_string()),
2828        }
2829    }
2830
2831    ToolchainReport { found, missing }
2832}
2833
2834fn collect_package_managers() -> PackageManagerReport {
2835    let checks = [
2836        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
2837        ToolCheck::new(
2838            "npm",
2839            &[
2840                CommandProbe::new("npm", &["--version"]),
2841                CommandProbe::new("npm.cmd", &["--version"]),
2842            ],
2843        ),
2844        ToolCheck::new(
2845            "pnpm",
2846            &[
2847                CommandProbe::new("pnpm", &["--version"]),
2848                CommandProbe::new("pnpm.cmd", &["--version"]),
2849            ],
2850        ),
2851        ToolCheck::new(
2852            "pip",
2853            &[
2854                CommandProbe::new("python", &["-m", "pip", "--version"]),
2855                CommandProbe::new("python3", &["-m", "pip", "--version"]),
2856                CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
2857                CommandProbe::new("py", &["-m", "pip", "--version"]),
2858                CommandProbe::new("pip", &["--version"]),
2859            ],
2860        ),
2861        ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
2862        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
2863        ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
2864        ToolCheck::new(
2865            "choco",
2866            &[
2867                CommandProbe::new("choco", &["--version"]),
2868                CommandProbe::new("choco.exe", &["--version"]),
2869            ],
2870        ),
2871        ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
2872    ];
2873
2874    let mut found = Vec::new();
2875    for check in checks {
2876        match check.detect() {
2877            Some(version) => found.push((check.label.to_string(), version)),
2878            None => {}
2879        }
2880    }
2881
2882    PackageManagerReport { found }
2883}
2884
2885#[derive(Clone)]
2886struct ToolCheck {
2887    label: &'static str,
2888    probes: Vec<CommandProbe>,
2889}
2890
2891impl ToolCheck {
2892    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
2893        Self {
2894            label,
2895            probes: probes.to_vec(),
2896        }
2897    }
2898
2899    fn detect(&self) -> Option<String> {
2900        for probe in &self.probes {
2901            if let Some(output) = capture_first_line(probe.program, probe.args) {
2902                return Some(output);
2903            }
2904        }
2905        None
2906    }
2907}
2908
2909#[derive(Clone, Copy)]
2910struct CommandProbe {
2911    program: &'static str,
2912    args: &'static [&'static str],
2913}
2914
2915impl CommandProbe {
2916    const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
2917        Self { program, args }
2918    }
2919}
2920
2921fn build_env_doctor_findings(
2922    toolchains: &ToolchainReport,
2923    package_managers: &PackageManagerReport,
2924    path_stats: &PathAnalysis,
2925) -> Vec<String> {
2926    let found_tools = toolchains
2927        .found
2928        .iter()
2929        .map(|(label, _)| label.as_str())
2930        .collect::<HashSet<_>>();
2931    let found_managers = package_managers
2932        .found
2933        .iter()
2934        .map(|(label, _)| label.as_str())
2935        .collect::<HashSet<_>>();
2936
2937    let mut findings = Vec::new();
2938
2939    if path_stats.duplicate_entries.len() > 0 {
2940        findings.push(format!(
2941            "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
2942            path_stats.duplicate_entries.len()
2943        ));
2944    }
2945    if path_stats.missing_entries.len() > 0 {
2946        findings.push(format!(
2947            "PATH contains {} entries that do not exist on disk.",
2948            path_stats.missing_entries.len()
2949        ));
2950    }
2951    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
2952        findings.push(
2953            "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
2954                .to_string(),
2955        );
2956    }
2957    if found_tools.contains("node")
2958        && !found_managers.contains("npm")
2959        && !found_managers.contains("pnpm")
2960    {
2961        findings.push(
2962            "Node is present but no JavaScript package manager was detected (npm or pnpm)."
2963                .to_string(),
2964        );
2965    }
2966    if found_tools.contains("python")
2967        && !found_managers.contains("pip")
2968        && !found_managers.contains("uv")
2969        && !found_managers.contains("pipx")
2970    {
2971        findings.push(
2972            "Python is present but no Python package manager was detected (pip, uv, or pipx)."
2973                .to_string(),
2974        );
2975    }
2976    let windows_manager_count = ["winget", "choco", "scoop"]
2977        .iter()
2978        .filter(|label| found_managers.contains(**label))
2979        .count();
2980    if windows_manager_count > 1 {
2981        findings.push(
2982            "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
2983                .to_string(),
2984        );
2985    }
2986    if findings.is_empty() && !found_managers.is_empty() {
2987        findings.push(
2988            "Core package-manager coverage looks healthy for a normal developer workstation."
2989                .to_string(),
2990        );
2991    }
2992
2993    findings
2994}
2995
2996fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
2997    let output = std::process::Command::new(program)
2998        .args(args)
2999        .output()
3000        .ok()?;
3001    if !output.status.success() {
3002        return None;
3003    }
3004
3005    let stdout = if output.stdout.is_empty() {
3006        String::from_utf8_lossy(&output.stderr).into_owned()
3007    } else {
3008        String::from_utf8_lossy(&output.stdout).into_owned()
3009    };
3010
3011    stdout
3012        .lines()
3013        .map(str::trim)
3014        .find(|line| !line.is_empty())
3015        .map(|line| line.to_string())
3016}
3017
3018fn human_bytes(bytes: u64) -> String {
3019    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3020    let mut value = bytes as f64;
3021    let mut unit_index = 0usize;
3022
3023    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3024        value /= 1024.0;
3025        unit_index += 1;
3026    }
3027
3028    if unit_index == 0 {
3029        format!("{} {}", bytes, UNITS[unit_index])
3030    } else {
3031        format!("{value:.1} {}", UNITS[unit_index])
3032    }
3033}
3034
3035#[cfg(target_os = "windows")]
3036fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3037    let mut adapters = Vec::new();
3038    let mut current: Option<NetworkAdapter> = None;
3039    let mut pending_dns = false;
3040
3041    for raw_line in text.lines() {
3042        let line = raw_line.trim_end();
3043        let trimmed = line.trim();
3044        if trimmed.is_empty() {
3045            pending_dns = false;
3046            continue;
3047        }
3048
3049        if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3050            if let Some(adapter) = current.take() {
3051                adapters.push(adapter);
3052            }
3053            current = Some(NetworkAdapter {
3054                name: trimmed.trim_end_matches(':').to_string(),
3055                ..NetworkAdapter::default()
3056            });
3057            pending_dns = false;
3058            continue;
3059        }
3060
3061        let Some(adapter) = current.as_mut() else {
3062            continue;
3063        };
3064
3065        if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3066            adapter.disconnected = true;
3067        }
3068
3069        if let Some(value) = value_after_colon(trimmed) {
3070            let normalized = normalize_ipconfig_value(value);
3071            if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3072                adapter.ipv4.push(normalized);
3073                pending_dns = false;
3074            } else if trimmed.starts_with("IPv6 Address")
3075                || trimmed.starts_with("Temporary IPv6 Address")
3076                || trimmed.starts_with("Link-local IPv6 Address")
3077            {
3078                if !normalized.is_empty() {
3079                    adapter.ipv6.push(normalized);
3080                }
3081                pending_dns = false;
3082            } else if trimmed.starts_with("Default Gateway") {
3083                if !normalized.is_empty() {
3084                    adapter.gateways.push(normalized);
3085                }
3086                pending_dns = false;
3087            } else if trimmed.starts_with("DNS Servers") {
3088                if !normalized.is_empty() {
3089                    adapter.dns_servers.push(normalized);
3090                }
3091                pending_dns = true;
3092            } else {
3093                pending_dns = false;
3094            }
3095        } else if pending_dns {
3096            let normalized = normalize_ipconfig_value(trimmed);
3097            if !normalized.is_empty() {
3098                adapter.dns_servers.push(normalized);
3099            }
3100        }
3101    }
3102
3103    if let Some(adapter) = current.take() {
3104        adapters.push(adapter);
3105    }
3106
3107    for adapter in &mut adapters {
3108        dedup_vec(&mut adapter.ipv4);
3109        dedup_vec(&mut adapter.ipv6);
3110        dedup_vec(&mut adapter.gateways);
3111        dedup_vec(&mut adapter.dns_servers);
3112    }
3113
3114    adapters
3115}
3116
3117#[cfg(not(target_os = "windows"))]
3118fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3119    let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3120
3121    for line in text.lines() {
3122        let cols: Vec<&str> = line.split_whitespace().collect();
3123        if cols.len() < 4 {
3124            continue;
3125        }
3126        let name = cols[1].trim_end_matches(':').to_string();
3127        let family = cols[2];
3128        let addr = cols[3].split('/').next().unwrap_or("").to_string();
3129        let entry = adapters
3130            .entry(name.clone())
3131            .or_insert_with(|| NetworkAdapter {
3132                name,
3133                ..NetworkAdapter::default()
3134            });
3135        match family {
3136            "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3137            "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3138            _ => {}
3139        }
3140    }
3141
3142    adapters.into_values().collect()
3143}
3144
3145#[cfg(not(target_os = "windows"))]
3146fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3147    for line in text.lines() {
3148        let cols: Vec<&str> = line.split_whitespace().collect();
3149        if cols.len() < 5 {
3150            continue;
3151        }
3152        let gateway = cols
3153            .windows(2)
3154            .find(|pair| pair[0] == "via")
3155            .map(|pair| pair[1].to_string());
3156        let dev = cols
3157            .windows(2)
3158            .find(|pair| pair[0] == "dev")
3159            .map(|pair| pair[1]);
3160        if let (Some(gateway), Some(dev)) = (gateway, dev) {
3161            if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3162                adapter.gateways.push(gateway);
3163            }
3164        }
3165    }
3166
3167    for adapter in adapters {
3168        dedup_vec(&mut adapter.gateways);
3169    }
3170}
3171
3172#[cfg(not(target_os = "windows"))]
3173fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3174    let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3175        return;
3176    };
3177    let mut dns_servers = text
3178        .lines()
3179        .filter_map(|line| line.strip_prefix("nameserver "))
3180        .map(str::trim)
3181        .filter(|value| !value.is_empty())
3182        .map(|value| value.to_string())
3183        .collect::<Vec<_>>();
3184    dedup_vec(&mut dns_servers);
3185    if dns_servers.is_empty() {
3186        return;
3187    }
3188    for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3189        adapter.dns_servers = dns_servers.clone();
3190    }
3191}
3192
3193#[cfg(target_os = "windows")]
3194fn value_after_colon(line: &str) -> Option<&str> {
3195    line.split_once(':').map(|(_, value)| value.trim())
3196}
3197
3198#[cfg(target_os = "windows")]
3199fn normalize_ipconfig_value(value: &str) -> String {
3200    value
3201        .trim()
3202        .trim_matches(['(', ')'])
3203        .trim_end_matches("(Preferred)")
3204        .trim()
3205        .to_string()
3206}
3207
3208fn dedup_vec(values: &mut Vec<String>) {
3209    let mut seen = HashSet::new();
3210    values.retain(|value| seen.insert(value.clone()));
3211}
3212
3213#[cfg(target_os = "windows")]
3214fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3215    let trimmed = text.trim();
3216    if trimmed.is_empty() {
3217        return Ok(Vec::new());
3218    }
3219
3220    let value: Value = serde_json::from_str(trimmed)
3221        .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3222    let entries = match value {
3223        Value::Array(items) => items,
3224        other => vec![other],
3225    };
3226
3227    let mut services = Vec::new();
3228    for entry in entries {
3229        let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3230            continue;
3231        };
3232        services.push(ServiceEntry {
3233            name: name.to_string(),
3234            status: entry
3235                .get("State")
3236                .and_then(|v| v.as_str())
3237                .unwrap_or("unknown")
3238                .to_string(),
3239            startup: entry
3240                .get("StartMode")
3241                .and_then(|v| v.as_str())
3242                .map(|v| v.to_string()),
3243            display_name: entry
3244                .get("DisplayName")
3245                .and_then(|v| v.as_str())
3246                .map(|v| v.to_string()),
3247            start_name: entry
3248                .get("StartName")
3249                .and_then(|v| v.as_str())
3250                .map(|v| v.to_string()),
3251        });
3252    }
3253
3254    Ok(services)
3255}
3256
3257#[cfg(not(target_os = "windows"))]
3258fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
3259    let mut startup_modes = std::collections::HashMap::<String, String>::new();
3260    for line in startup_text.lines() {
3261        let cols: Vec<&str> = line.split_whitespace().collect();
3262        if cols.len() < 2 {
3263            continue;
3264        }
3265        startup_modes.insert(cols[0].to_string(), cols[1].to_string());
3266    }
3267
3268    let mut services = Vec::new();
3269    for line in status_text.lines() {
3270        let cols: Vec<&str> = line.split_whitespace().collect();
3271        if cols.len() < 4 {
3272            continue;
3273        }
3274        let unit = cols[0];
3275        let load = cols[1];
3276        let active = cols[2];
3277        let sub = cols[3];
3278        let description = if cols.len() > 4 {
3279            Some(cols[4..].join(" "))
3280        } else {
3281            None
3282        };
3283        services.push(ServiceEntry {
3284            name: unit.to_string(),
3285            status: format!("{}/{}", active, sub),
3286            startup: startup_modes
3287                .get(unit)
3288                .cloned()
3289                .or_else(|| Some(load.to_string())),
3290            display_name: description,
3291            start_name: None,
3292        });
3293    }
3294
3295    services
3296}
3297
3298// ── health_report ─────────────────────────────────────────────────────────────
3299
3300/// Synthesized system health report — runs multiple checks and returns a
3301/// plain-English tiered verdict suitable for both developers and non-technical
3302/// users who just want to know if their machine is okay.
3303fn inspect_health_report() -> Result<String, String> {
3304    let mut needs_fix: Vec<String> = Vec::new();
3305    let mut watch: Vec<String> = Vec::new();
3306    let mut good: Vec<String> = Vec::new();
3307    let mut tips: Vec<String> = Vec::new();
3308
3309    health_check_disk(&mut needs_fix, &mut watch, &mut good);
3310    health_check_memory(&mut watch, &mut good);
3311    health_check_tools(&mut watch, &mut good, &mut tips);
3312    health_check_recent_errors(&mut watch, &mut tips);
3313
3314    let overall = if !needs_fix.is_empty() {
3315        "ACTION REQUIRED"
3316    } else if !watch.is_empty() {
3317        "WORTH A LOOK"
3318    } else {
3319        "ALL GOOD"
3320    };
3321
3322    let mut out = format!("System Health Report — {overall}\n\n");
3323
3324    if !needs_fix.is_empty() {
3325        out.push_str("Needs fixing:\n");
3326        for item in &needs_fix {
3327            out.push_str(&format!("  [!] {item}\n"));
3328        }
3329        out.push('\n');
3330    }
3331    if !watch.is_empty() {
3332        out.push_str("Worth watching:\n");
3333        for item in &watch {
3334            out.push_str(&format!("  [-] {item}\n"));
3335        }
3336        out.push('\n');
3337    }
3338    if !good.is_empty() {
3339        out.push_str("Looking good:\n");
3340        for item in &good {
3341            out.push_str(&format!("  [+] {item}\n"));
3342        }
3343        out.push('\n');
3344    }
3345    if !tips.is_empty() {
3346        out.push_str("To dig deeper:\n");
3347        for tip in &tips {
3348            out.push_str(&format!("  {tip}\n"));
3349        }
3350    }
3351
3352    Ok(out.trim_end().to_string())
3353}
3354
3355fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
3356    #[cfg(target_os = "windows")]
3357    {
3358        let script = r#"try {
3359    $d = Get-PSDrive C -ErrorAction Stop
3360    "$($d.Free)|$($d.Used)"
3361} catch { "ERR" }"#;
3362        if let Ok(out) = Command::new("powershell")
3363            .args(["-NoProfile", "-Command", script])
3364            .output()
3365        {
3366            let text = String::from_utf8_lossy(&out.stdout);
3367            let text = text.trim();
3368            if !text.starts_with("ERR") {
3369                let parts: Vec<&str> = text.split('|').collect();
3370                if parts.len() == 2 {
3371                    let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
3372                    let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
3373                    let total = free_bytes + used_bytes;
3374                    let free_gb = free_bytes / 1_073_741_824;
3375                    let pct_free = if total > 0 {
3376                        (free_bytes as f64 / total as f64 * 100.0) as u64
3377                    } else {
3378                        0
3379                    };
3380                    let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
3381                    if free_gb < 5 {
3382                        needs_fix.push(format!(
3383                            "{msg} — very low. Free up space or your system may slow down or stop working."
3384                        ));
3385                    } else if free_gb < 15 {
3386                        watch.push(format!("{msg} — getting low, consider cleaning up."));
3387                    } else {
3388                        good.push(msg);
3389                    }
3390                    return;
3391                }
3392            }
3393        }
3394        watch.push("Disk: could not read free space from C: drive.".to_string());
3395    }
3396
3397    #[cfg(not(target_os = "windows"))]
3398    {
3399        if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
3400            let text = String::from_utf8_lossy(&out.stdout);
3401            for line in text.lines().skip(1) {
3402                let cols: Vec<&str> = line.split_whitespace().collect();
3403                if cols.len() >= 5 {
3404                    let avail_str = cols[3].trim_end_matches('G');
3405                    let use_pct = cols[4].trim_end_matches('%');
3406                    let avail_gb: u64 = avail_str.parse().unwrap_or(0);
3407                    let used_pct: u64 = use_pct.parse().unwrap_or(0);
3408                    let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
3409                    if avail_gb < 5 {
3410                        needs_fix.push(format!(
3411                            "{msg} — very low. Free up space to prevent system issues."
3412                        ));
3413                    } else if avail_gb < 15 {
3414                        watch.push(format!("{msg} — getting low."));
3415                    } else {
3416                        good.push(msg);
3417                    }
3418                    return;
3419                }
3420            }
3421        }
3422        watch.push("Disk: could not determine free space.".to_string());
3423    }
3424}
3425
3426fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
3427    #[cfg(target_os = "windows")]
3428    {
3429        let script = r#"try {
3430    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
3431    "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
3432} catch { "ERR" }"#;
3433        if let Ok(out) = Command::new("powershell")
3434            .args(["-NoProfile", "-Command", script])
3435            .output()
3436        {
3437            let text = String::from_utf8_lossy(&out.stdout);
3438            let text = text.trim();
3439            if !text.starts_with("ERR") {
3440                let parts: Vec<&str> = text.split('|').collect();
3441                if parts.len() == 2 {
3442                    let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
3443                    let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
3444                    if total_kb > 0 {
3445                        let free_gb = free_kb / 1_048_576;
3446                        let total_gb = total_kb / 1_048_576;
3447                        let free_pct = free_kb * 100 / total_kb;
3448                        let msg = format!(
3449                            "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
3450                        );
3451                        if free_pct < 10 {
3452                            watch.push(format!(
3453                                "{msg} — very low. Close unused apps to free up memory."
3454                            ));
3455                        } else if free_pct < 25 {
3456                            watch.push(format!("{msg} — running a bit low."));
3457                        } else {
3458                            good.push(msg);
3459                        }
3460                        return;
3461                    }
3462                }
3463            }
3464        }
3465    }
3466
3467    #[cfg(not(target_os = "windows"))]
3468    {
3469        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
3470            let mut total_kb = 0u64;
3471            let mut avail_kb = 0u64;
3472            for line in content.lines() {
3473                if line.starts_with("MemTotal:") {
3474                    total_kb = line
3475                        .split_whitespace()
3476                        .nth(1)
3477                        .and_then(|v| v.parse().ok())
3478                        .unwrap_or(0);
3479                } else if line.starts_with("MemAvailable:") {
3480                    avail_kb = line
3481                        .split_whitespace()
3482                        .nth(1)
3483                        .and_then(|v| v.parse().ok())
3484                        .unwrap_or(0);
3485                }
3486            }
3487            if total_kb > 0 {
3488                let free_gb = avail_kb / 1_048_576;
3489                let total_gb = total_kb / 1_048_576;
3490                let free_pct = avail_kb * 100 / total_kb;
3491                let msg =
3492                    format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
3493                if free_pct < 10 {
3494                    watch.push(format!("{msg} — very low. Close unused apps."));
3495                } else if free_pct < 25 {
3496                    watch.push(format!("{msg} — running a bit low."));
3497                } else {
3498                    good.push(msg);
3499                }
3500            }
3501        }
3502    }
3503}
3504
3505fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
3506    let tool_checks: &[(&str, &str, &str)] = &[
3507        ("git", "--version", "Git"),
3508        ("cargo", "--version", "Rust / Cargo"),
3509        ("node", "--version", "Node.js"),
3510        ("python", "--version", "Python"),
3511        ("python3", "--version", "Python 3"),
3512        ("npm", "--version", "npm"),
3513    ];
3514
3515    let mut found: Vec<String> = Vec::new();
3516    let mut missing: Vec<String> = Vec::new();
3517    let mut python_found = false;
3518
3519    for (cmd, arg, label) in tool_checks {
3520        if cmd.starts_with("python") && python_found {
3521            continue;
3522        }
3523        let ok = Command::new(cmd)
3524            .arg(arg)
3525            .stdout(std::process::Stdio::null())
3526            .stderr(std::process::Stdio::null())
3527            .status()
3528            .map(|s| s.success())
3529            .unwrap_or(false);
3530        if ok {
3531            found.push((*label).to_string());
3532            if cmd.starts_with("python") {
3533                python_found = true;
3534            }
3535        } else if !cmd.starts_with("python") || !python_found {
3536            missing.push((*label).to_string());
3537        }
3538    }
3539
3540    if !found.is_empty() {
3541        good.push(format!("Dev tools found: {}", found.join(", ")));
3542    }
3543    if !missing.is_empty() {
3544        watch.push(format!(
3545            "Not installed (or not on PATH): {} — only matters if you need them",
3546            missing.join(", ")
3547        ));
3548        tips.push(
3549            "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
3550                .to_string(),
3551        );
3552    }
3553}
3554
3555fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
3556    #[cfg(target_os = "windows")]
3557    {
3558        let script = r#"try {
3559    $cutoff = (Get-Date).AddHours(-24)
3560    $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
3561    $count
3562} catch { "0" }"#;
3563        if let Ok(out) = Command::new("powershell")
3564            .args(["-NoProfile", "-Command", script])
3565            .output()
3566        {
3567            let text = String::from_utf8_lossy(&out.stdout);
3568            let count: u64 = text.trim().parse().unwrap_or(0);
3569            if count > 0 {
3570                watch.push(format!(
3571                    "{count} critical/error event{} in Windows event logs in the last 24 hours.",
3572                    if count == 1 { "" } else { "s" }
3573                ));
3574                tips.push(
3575                    "Run inspect_host(topic=\"log_check\") to see the actual error messages."
3576                        .to_string(),
3577                );
3578            }
3579        }
3580    }
3581
3582    #[cfg(not(target_os = "windows"))]
3583    {
3584        if let Ok(out) = Command::new("journalctl")
3585            .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
3586            .output()
3587        {
3588            let text = String::from_utf8_lossy(&out.stdout);
3589            if !text.trim().is_empty() {
3590                watch.push("Critical/error entries found in the system journal.".to_string());
3591                tips.push(
3592                    "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
3593                );
3594            }
3595        }
3596    }
3597}
3598
3599// ── log_check ─────────────────────────────────────────────────────────────────
3600
3601fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
3602    let mut out = String::from("Host inspection: log_check\n\n");
3603
3604    #[cfg(target_os = "windows")]
3605    {
3606        // Pull recent critical/error events from Windows Application and System logs.
3607        let hours = lookback_hours.unwrap_or(24);
3608        out.push_str(&format!(
3609            "Checking System/Application logs from the last {} hours...\n\n",
3610            hours
3611        ));
3612
3613        let n = max_entries.clamp(1, 50);
3614        let script = format!(
3615            r#"try {{
3616    $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
3617    if (-not $events) {{ "NO_EVENTS"; exit }}
3618    $events | Select-Object -First {n} | ForEach-Object {{
3619        $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
3620        $line
3621    }}
3622}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
3623            hours = hours,
3624            n = n
3625        );
3626        let output = Command::new("powershell")
3627            .args(["-NoProfile", "-Command", &script])
3628            .output()
3629            .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
3630
3631        let raw = String::from_utf8_lossy(&output.stdout);
3632        let text = raw.trim();
3633
3634        if text.is_empty() || text == "NO_EVENTS" {
3635            out.push_str("No critical or error events found in Application/System logs.\n");
3636            return Ok(out.trim_end().to_string());
3637        }
3638        if text.starts_with("ERROR:") {
3639            out.push_str(&format!("Warning: event log query returned: {text}\n"));
3640            return Ok(out.trim_end().to_string());
3641        }
3642
3643        let mut count = 0usize;
3644        for line in text.lines() {
3645            let parts: Vec<&str> = line.splitn(4, '|').collect();
3646            if parts.len() == 4 {
3647                let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
3648                out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
3649                count += 1;
3650            }
3651        }
3652        out.push_str(&format!(
3653            "\nEvents shown: {count} (critical/error from Application + System logs)\n"
3654        ));
3655    }
3656
3657    #[cfg(not(target_os = "windows"))]
3658    {
3659        let _ = lookback_hours;
3660        // Use journalctl on Linux/macOS if available.
3661        let n = max_entries.clamp(1, 50).to_string();
3662        let output = Command::new("journalctl")
3663            .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
3664            .output();
3665
3666        match output {
3667            Ok(o) if o.status.success() => {
3668                let text = String::from_utf8_lossy(&o.stdout);
3669                let trimmed = text.trim();
3670                if trimmed.is_empty() || trimmed.contains("No entries") {
3671                    out.push_str("No critical or error entries found in the system journal.\n");
3672                } else {
3673                    out.push_str(trimmed);
3674                    out.push('\n');
3675                    out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
3676                }
3677            }
3678            _ => {
3679                // Fallback: check /var/log/syslog or /var/log/messages
3680                let log_paths = ["/var/log/syslog", "/var/log/messages"];
3681                let mut found = false;
3682                for log_path in &log_paths {
3683                    if let Ok(content) = std::fs::read_to_string(log_path) {
3684                        let lines: Vec<&str> = content.lines().collect();
3685                        let tail: Vec<&str> = lines
3686                            .iter()
3687                            .rev()
3688                            .filter(|l| {
3689                                let l_lower = l.to_ascii_lowercase();
3690                                l_lower.contains("error") || l_lower.contains("crit")
3691                            })
3692                            .take(max_entries)
3693                            .copied()
3694                            .collect::<Vec<_>>()
3695                            .into_iter()
3696                            .rev()
3697                            .collect();
3698                        if !tail.is_empty() {
3699                            out.push_str(&format!("Source: {log_path}\n"));
3700                            for l in &tail {
3701                                out.push_str(l);
3702                                out.push('\n');
3703                            }
3704                            found = true;
3705                            break;
3706                        }
3707                    }
3708                }
3709                if !found {
3710                    out.push_str(
3711                        "journalctl not found and no readable syslog detected on this system.\n",
3712                    );
3713                }
3714            }
3715        }
3716    }
3717
3718    Ok(out.trim_end().to_string())
3719}
3720
3721// ── startup_items ─────────────────────────────────────────────────────────────
3722
3723fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
3724    let mut out = String::from("Host inspection: startup_items\n\n");
3725
3726    #[cfg(target_os = "windows")]
3727    {
3728        // Query both HKLM and HKCU Run keys.
3729        let script = r#"
3730$hives = @(
3731    @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
3732    @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
3733    @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
3734)
3735foreach ($h in $hives) {
3736    try {
3737        $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
3738        $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
3739            "$($h.Hive)|$($_.Name)|$($_.Value)"
3740        }
3741    } catch {}
3742}
3743"#;
3744        let output = Command::new("powershell")
3745            .args(["-NoProfile", "-Command", script])
3746            .output()
3747            .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
3748
3749        let raw = String::from_utf8_lossy(&output.stdout);
3750        let text = raw.trim();
3751
3752        let entries: Vec<(String, String, String)> = text
3753            .lines()
3754            .filter_map(|l| {
3755                let parts: Vec<&str> = l.splitn(3, '|').collect();
3756                if parts.len() == 3 {
3757                    Some((
3758                        parts[0].to_string(),
3759                        parts[1].to_string(),
3760                        parts[2].to_string(),
3761                    ))
3762                } else {
3763                    None
3764                }
3765            })
3766            .take(max_entries)
3767            .collect();
3768
3769        if entries.is_empty() {
3770            out.push_str("No startup entries found in the Windows Run registry keys.\n");
3771        } else {
3772            out.push_str("Registry run keys (programs that start with Windows):\n\n");
3773            let mut last_hive = String::new();
3774            for (hive, name, value) in &entries {
3775                if *hive != last_hive {
3776                    out.push_str(&format!("[{}]\n", hive));
3777                    last_hive = hive.clone();
3778                }
3779                // Truncate very long values (paths with many args)
3780                let display = if value.len() > 100 {
3781                    format!("{}…", &value[..100])
3782                } else {
3783                    value.clone()
3784                };
3785                out.push_str(&format!("  {name}: {display}\n"));
3786            }
3787            out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
3788        }
3789
3790        // 3. Unified Startup Command check (Task Manager style)
3791        let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { "  $($_.Name): $($_.Command) ($($_.Location))" }"#;
3792        if let Ok(unified_out) = Command::new("powershell")
3793            .args(["-NoProfile", "-Command", unified_script])
3794            .output()
3795        {
3796            let unified_text = String::from_utf8_lossy(&unified_out.stdout);
3797            let trimmed = unified_text.trim();
3798            if !trimmed.is_empty() {
3799                out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
3800                out.push_str(trimmed);
3801                out.push('\n');
3802            }
3803        }
3804    }
3805
3806    #[cfg(not(target_os = "windows"))]
3807    {
3808        // On Linux: systemd enabled services + cron @reboot entries.
3809        let output = Command::new("systemctl")
3810            .args([
3811                "list-unit-files",
3812                "--type=service",
3813                "--state=enabled",
3814                "--no-legend",
3815                "--no-pager",
3816                "--plain",
3817            ])
3818            .output();
3819
3820        match output {
3821            Ok(o) if o.status.success() => {
3822                let text = String::from_utf8_lossy(&o.stdout);
3823                let services: Vec<&str> = text
3824                    .lines()
3825                    .filter(|l| !l.trim().is_empty())
3826                    .take(max_entries)
3827                    .collect();
3828                if services.is_empty() {
3829                    out.push_str("No enabled systemd services found.\n");
3830                } else {
3831                    out.push_str("Enabled systemd services (run at boot):\n\n");
3832                    for s in &services {
3833                        out.push_str(&format!("  {s}\n"));
3834                    }
3835                    out.push_str(&format!(
3836                        "\nShowing {} of enabled services.\n",
3837                        services.len()
3838                    ));
3839                }
3840            }
3841            _ => {
3842                out.push_str(
3843                    "systemctl not found on this system. Cannot enumerate startup services.\n",
3844                );
3845            }
3846        }
3847
3848        // Check @reboot cron entries.
3849        if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
3850            let cron_text = String::from_utf8_lossy(&cron_out.stdout);
3851            let reboot_entries: Vec<&str> = cron_text
3852                .lines()
3853                .filter(|l| l.trim_start().starts_with("@reboot"))
3854                .collect();
3855            if !reboot_entries.is_empty() {
3856                out.push_str("\nCron @reboot entries:\n");
3857                for e in reboot_entries {
3858                    out.push_str(&format!("  {e}\n"));
3859                }
3860            }
3861        }
3862    }
3863
3864    Ok(out.trim_end().to_string())
3865}
3866
3867fn inspect_os_config() -> Result<String, String> {
3868    let mut out = String::from("Host inspection: OS Configuration\n\n");
3869
3870    #[cfg(target_os = "windows")]
3871    {
3872        // Power Plan
3873        if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
3874            let power_str = String::from_utf8_lossy(&power_out.stdout);
3875            out.push_str("=== Power Plan ===\n");
3876            out.push_str(power_str.trim());
3877            out.push_str("\n\n");
3878        }
3879
3880        // Firewall Status
3881        let fw_script =
3882            "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
3883        if let Ok(fw_out) = Command::new("powershell")
3884            .args(["-NoProfile", "-Command", fw_script])
3885            .output()
3886        {
3887            let fw_str = String::from_utf8_lossy(&fw_out.stdout);
3888            out.push_str("=== Firewall Profiles ===\n");
3889            out.push_str(fw_str.trim());
3890            out.push_str("\n\n");
3891        }
3892
3893        // System Uptime
3894        let uptime_script =
3895            "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
3896        if let Ok(uptime_out) = Command::new("powershell")
3897            .args(["-NoProfile", "-Command", uptime_script])
3898            .output()
3899        {
3900            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
3901            out.push_str("=== System Uptime (Last Boot) ===\n");
3902            out.push_str(uptime_str.trim());
3903            out.push_str("\n\n");
3904        }
3905    }
3906
3907    #[cfg(not(target_os = "windows"))]
3908    {
3909        // Uptime
3910        if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
3911            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
3912            out.push_str("=== System Uptime ===\n");
3913            out.push_str(uptime_str.trim());
3914            out.push_str("\n\n");
3915        }
3916
3917        // Firewall (ufw status if available)
3918        if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
3919            let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
3920            if !ufw_str.trim().is_empty() {
3921                out.push_str("=== Firewall (UFW) ===\n");
3922                out.push_str(ufw_str.trim());
3923                out.push_str("\n\n");
3924            }
3925        }
3926    }
3927    Ok(out.trim_end().to_string())
3928}
3929
3930pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
3931    let action = args
3932        .get("action")
3933        .and_then(|v| v.as_str())
3934        .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
3935
3936    let target = args
3937        .get("target")
3938        .and_then(|v| v.as_str())
3939        .unwrap_or("")
3940        .trim();
3941
3942    if target.is_empty() && action != "clear_temp" {
3943        return Err("Missing required argument: 'target' for this action".to_string());
3944    }
3945
3946    match action {
3947        "install_package" => {
3948            #[cfg(target_os = "windows")]
3949            {
3950                let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
3951                match Command::new("powershell")
3952                    .args(["-NoProfile", "-Command", &cmd])
3953                    .output()
3954                {
3955                    Ok(out) => Ok(format!(
3956                        "Executed remediation (winget install):\n{}",
3957                        String::from_utf8_lossy(&out.stdout)
3958                    )),
3959                    Err(e) => Err(format!("Failed to run winget: {}", e)),
3960                }
3961            }
3962            #[cfg(not(target_os = "windows"))]
3963            {
3964                Err(
3965                    "install_package via wrapper is only supported on Windows currently (winget)"
3966                        .to_string(),
3967                )
3968            }
3969        }
3970        "restart_service" => {
3971            #[cfg(target_os = "windows")]
3972            {
3973                let cmd = format!("Restart-Service -Name {} -Force", target);
3974                match Command::new("powershell")
3975                    .args(["-NoProfile", "-Command", &cmd])
3976                    .output()
3977                {
3978                    Ok(out) => {
3979                        let err_str = String::from_utf8_lossy(&out.stderr);
3980                        if !err_str.is_empty() {
3981                            return Err(format!("Error restarting service:\n{}", err_str));
3982                        }
3983                        Ok(format!("Successfully restarted service: {}", target))
3984                    }
3985                    Err(e) => Err(format!("Failed to restart service: {}", e)),
3986                }
3987            }
3988            #[cfg(not(target_os = "windows"))]
3989            {
3990                Err(
3991                    "restart_service via wrapper is only supported on Windows currently"
3992                        .to_string(),
3993                )
3994            }
3995        }
3996        "clear_temp" => {
3997            #[cfg(target_os = "windows")]
3998            {
3999                let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
4000                match Command::new("powershell")
4001                    .args(["-NoProfile", "-Command", cmd])
4002                    .output()
4003                {
4004                    Ok(_) => Ok("Successfully cleared temporary files".to_string()),
4005                    Err(e) => Err(format!("Failed to clear temp: {}", e)),
4006                }
4007            }
4008            #[cfg(not(target_os = "windows"))]
4009            {
4010                Err("clear_temp via wrapper is only supported on Windows currently".to_string())
4011            }
4012        }
4013        other => Err(format!("Unknown remediation action: {}", other)),
4014    }
4015}
4016
4017// ── storage ───────────────────────────────────────────────────────────────────
4018
4019fn inspect_storage(max_entries: usize) -> Result<String, String> {
4020    let mut out = String::from("Host inspection: storage\n\n");
4021    let _ = max_entries; // used by non-Windows branch
4022
4023    // ── Drive overview ────────────────────────────────────────────────────────
4024    out.push_str("Drives:\n");
4025
4026    #[cfg(target_os = "windows")]
4027    {
4028        let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
4029    $free = $_.Free
4030    $used = $_.Used
4031    if ($free -eq $null) { $free = 0 }
4032    if ($used -eq $null) { $used = 0 }
4033    $total = $free + $used
4034    "$($_.Name)|$free|$used|$total"
4035}"#;
4036        match Command::new("powershell")
4037            .args(["-NoProfile", "-Command", script])
4038            .output()
4039        {
4040            Ok(o) => {
4041                let text = String::from_utf8_lossy(&o.stdout);
4042                let mut drive_count = 0usize;
4043                for line in text.lines() {
4044                    let parts: Vec<&str> = line.trim().split('|').collect();
4045                    if parts.len() == 4 {
4046                        let name = parts[0];
4047                        let free: u64 = parts[1].parse().unwrap_or(0);
4048                        let total: u64 = parts[3].parse().unwrap_or(0);
4049                        if total == 0 {
4050                            continue;
4051                        }
4052                        let free_gb = free / 1_073_741_824;
4053                        let total_gb = total / 1_073_741_824;
4054                        let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
4055                        let bar_len = 20usize;
4056                        let filled = (used_pct as usize * bar_len / 100).min(bar_len);
4057                        let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
4058                        let warn = if free_gb < 5 {
4059                            " [!] CRITICALLY LOW"
4060                        } else if free_gb < 15 {
4061                            " [-] LOW"
4062                        } else {
4063                            ""
4064                        };
4065                        out.push_str(&format!(
4066                            "  {name}:  [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
4067                        ));
4068                        drive_count += 1;
4069                    }
4070                }
4071                if drive_count == 0 {
4072                    out.push_str("  (could not enumerate drives)\n");
4073                }
4074            }
4075            Err(e) => out.push_str(&format!("  (drive scan failed: {e})\n")),
4076        }
4077
4078        // ── Real-time Performance (Latency) ──────────────────────────────────
4079        let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
4080        match Command::new("powershell")
4081            .args(["-NoProfile", "-Command", latency_script])
4082            .output()
4083        {
4084            Ok(o) => {
4085                let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
4086                if !text.is_empty() {
4087                    out.push_str("\nReal-time Disk Intensity:\n");
4088                    out.push_str(&format!("  Average Disk Queue Length: {text}\n"));
4089                    if let Ok(q) = text.parse::<f64>() {
4090                        if q > 2.0 {
4091                            out.push_str(
4092                                "  [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
4093                            );
4094                        } else {
4095                            out.push_str("  [~] Disk latency is within healthy bounds.\n");
4096                        }
4097                    }
4098                }
4099            }
4100            Err(_) => {}
4101        }
4102    }
4103
4104    #[cfg(not(target_os = "windows"))]
4105    {
4106        match Command::new("df")
4107            .args(["-h", "--output=target,size,avail,pcent"])
4108            .output()
4109        {
4110            Ok(o) => {
4111                let text = String::from_utf8_lossy(&o.stdout);
4112                let mut count = 0usize;
4113                for line in text.lines().skip(1) {
4114                    let cols: Vec<&str> = line.split_whitespace().collect();
4115                    if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
4116                        out.push_str(&format!(
4117                            "  {}  size: {}  avail: {}  used: {}\n",
4118                            cols[0], cols[1], cols[2], cols[3]
4119                        ));
4120                        count += 1;
4121                        if count >= max_entries {
4122                            break;
4123                        }
4124                    }
4125                }
4126            }
4127            Err(e) => out.push_str(&format!("  (df failed: {e})\n")),
4128        }
4129    }
4130
4131    // ── Large developer cache directories ─────────────────────────────────────
4132    out.push_str("\nLarge developer cache directories (if present):\n");
4133
4134    #[cfg(target_os = "windows")]
4135    {
4136        let home = std::env::var("USERPROFILE").unwrap_or_default();
4137        let check_dirs: &[(&str, &str)] = &[
4138            ("Temp", r"AppData\Local\Temp"),
4139            ("npm cache", r"AppData\Roaming\npm-cache"),
4140            ("Cargo registry", r".cargo\registry"),
4141            ("Cargo git", r".cargo\git"),
4142            ("pip cache", r"AppData\Local\pip\cache"),
4143            ("Yarn cache", r"AppData\Local\Yarn\Cache"),
4144            (".rustup toolchains", r".rustup\toolchains"),
4145            ("node_modules (home)", r"node_modules"),
4146        ];
4147
4148        let mut found_any = false;
4149        for (label, rel) in check_dirs {
4150            let full = format!(r"{}\{}", home, rel);
4151            let path = std::path::Path::new(&full);
4152            if path.exists() {
4153                // Quick size estimate via PowerShell (non-blocking cap at 5s)
4154                let size_script = format!(
4155                    r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
4156                    full.replace('\'', "''")
4157                );
4158                let size_mb = Command::new("powershell")
4159                    .args(["-NoProfile", "-Command", &size_script])
4160                    .output()
4161                    .ok()
4162                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
4163                    .unwrap_or_else(|| "?".to_string());
4164                out.push_str(&format!("  {label}: {size_mb} MB  ({full})\n"));
4165                found_any = true;
4166            }
4167        }
4168        if !found_any {
4169            out.push_str("  (none of the common cache directories found)\n");
4170        }
4171
4172        out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
4173    }
4174
4175    #[cfg(not(target_os = "windows"))]
4176    {
4177        let home = std::env::var("HOME").unwrap_or_default();
4178        let check_dirs: &[(&str, &str)] = &[
4179            ("npm cache", ".npm"),
4180            ("Cargo registry", ".cargo/registry"),
4181            ("pip cache", ".cache/pip"),
4182            (".rustup toolchains", ".rustup/toolchains"),
4183            ("Yarn cache", ".cache/yarn"),
4184        ];
4185        let mut found_any = false;
4186        for (label, rel) in check_dirs {
4187            let full = format!("{}/{}", home, rel);
4188            if std::path::Path::new(&full).exists() {
4189                let size = Command::new("du")
4190                    .args(["-sh", &full])
4191                    .output()
4192                    .ok()
4193                    .map(|o| {
4194                        let s = String::from_utf8_lossy(&o.stdout);
4195                        s.split_whitespace().next().unwrap_or("?").to_string()
4196                    })
4197                    .unwrap_or_else(|| "?".to_string());
4198                out.push_str(&format!("  {label}: {size}  ({full})\n"));
4199                found_any = true;
4200            }
4201        }
4202        if !found_any {
4203            out.push_str("  (none of the common cache directories found)\n");
4204        }
4205    }
4206
4207    Ok(out.trim_end().to_string())
4208}
4209
4210// ── hardware ──────────────────────────────────────────────────────────────────
4211
4212fn inspect_hardware() -> Result<String, String> {
4213    let mut out = String::from("Host inspection: hardware\n\n");
4214
4215    #[cfg(target_os = "windows")]
4216    {
4217        // CPU
4218        let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
4219    "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
4220} | Select-Object -First 1"#;
4221        if let Ok(o) = Command::new("powershell")
4222            .args(["-NoProfile", "-Command", cpu_script])
4223            .output()
4224        {
4225            let text = String::from_utf8_lossy(&o.stdout);
4226            let text = text.trim();
4227            let parts: Vec<&str> = text.split('|').collect();
4228            if parts.len() == 4 {
4229                out.push_str(&format!(
4230                    "CPU: {}\n  {} physical cores, {} logical processors, {:.1} GHz\n\n",
4231                    parts[0],
4232                    parts[1],
4233                    parts[2],
4234                    parts[3].parse::<f32>().unwrap_or(0.0)
4235                ));
4236            } else {
4237                out.push_str(&format!("CPU: {text}\n\n"));
4238            }
4239        }
4240
4241        // RAM (total installed + speed)
4242        let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
4243$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
4244$speed = ($sticks | Select-Object -First 1).Speed
4245"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
4246        if let Ok(o) = Command::new("powershell")
4247            .args(["-NoProfile", "-Command", ram_script])
4248            .output()
4249        {
4250            let text = String::from_utf8_lossy(&o.stdout);
4251            out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
4252        }
4253
4254        // GPU(s)
4255        let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
4256    "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
4257}"#;
4258        if let Ok(o) = Command::new("powershell")
4259            .args(["-NoProfile", "-Command", gpu_script])
4260            .output()
4261        {
4262            let text = String::from_utf8_lossy(&o.stdout);
4263            let lines: Vec<&str> = text.lines().collect();
4264            if !lines.is_empty() {
4265                out.push_str("GPU(s):\n");
4266                for line in lines.iter().filter(|l| !l.trim().is_empty()) {
4267                    let parts: Vec<&str> = line.trim().split('|').collect();
4268                    if parts.len() == 3 {
4269                        let res = if parts[2] == "x" || parts[2].starts_with('0') {
4270                            String::new()
4271                        } else {
4272                            format!(" — {}@display", parts[2])
4273                        };
4274                        out.push_str(&format!(
4275                            "  {}\n    Driver: {}{}\n",
4276                            parts[0], parts[1], res
4277                        ));
4278                    } else {
4279                        out.push_str(&format!("  {}\n", line.trim()));
4280                    }
4281                }
4282                out.push('\n');
4283            }
4284        }
4285
4286        // Motherboard + BIOS + Virtualization
4287        let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
4288$bios = Get-CimInstance Win32_BIOS
4289$cs = Get-CimInstance Win32_ComputerSystem
4290$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
4291$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
4292"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
4293        if let Ok(o) = Command::new("powershell")
4294            .args(["-NoProfile", "-Command", mb_script])
4295            .output()
4296        {
4297            let text = String::from_utf8_lossy(&o.stdout);
4298            let text = text.trim().trim_matches('"');
4299            let parts: Vec<&str> = text.split('|').collect();
4300            if parts.len() == 4 {
4301                out.push_str(&format!(
4302                    "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
4303                    parts[0].trim(),
4304                    parts[1].trim(),
4305                    parts[2].trim(),
4306                    parts[3].trim()
4307                ));
4308            }
4309        }
4310
4311        // Display(s)
4312        let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
4313    "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
4314}"#;
4315        if let Ok(o) = Command::new("powershell")
4316            .args(["-NoProfile", "-Command", disp_script])
4317            .output()
4318        {
4319            let text = String::from_utf8_lossy(&o.stdout);
4320            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4321            if !lines.is_empty() {
4322                out.push_str("Display(s):\n");
4323                for line in &lines {
4324                    let parts: Vec<&str> = line.trim().split('|').collect();
4325                    if parts.len() == 2 {
4326                        out.push_str(&format!("  {} — {}\n", parts[0].trim(), parts[1]));
4327                    }
4328                }
4329            }
4330        }
4331    }
4332
4333    #[cfg(not(target_os = "windows"))]
4334    {
4335        // CPU via /proc/cpuinfo
4336        if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
4337            let model = content
4338                .lines()
4339                .find(|l| l.starts_with("model name"))
4340                .and_then(|l| l.split(':').nth(1))
4341                .map(str::trim)
4342                .unwrap_or("unknown");
4343            let cores = content
4344                .lines()
4345                .filter(|l| l.starts_with("processor"))
4346                .count();
4347            out.push_str(&format!("CPU: {model}\n  {cores} logical processors\n\n"));
4348        }
4349
4350        // RAM
4351        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4352            let total_kb: u64 = content
4353                .lines()
4354                .find(|l| l.starts_with("MemTotal:"))
4355                .and_then(|l| l.split_whitespace().nth(1))
4356                .and_then(|v| v.parse().ok())
4357                .unwrap_or(0);
4358            let total_gb = total_kb / 1_048_576;
4359            out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
4360        }
4361
4362        // GPU via lspci
4363        if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
4364            let text = String::from_utf8_lossy(&o.stdout);
4365            let gpu_lines: Vec<&str> = text
4366                .lines()
4367                .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
4368                .collect();
4369            if !gpu_lines.is_empty() {
4370                out.push_str("GPU(s):\n");
4371                for l in gpu_lines {
4372                    out.push_str(&format!("  {l}\n"));
4373                }
4374                out.push('\n');
4375            }
4376        }
4377
4378        // DMI/BIOS info
4379        if let Ok(o) = Command::new("dmidecode")
4380            .args(["-t", "baseboard", "-t", "bios"])
4381            .output()
4382        {
4383            let text = String::from_utf8_lossy(&o.stdout);
4384            out.push_str("Motherboard/BIOS:\n");
4385            for line in text
4386                .lines()
4387                .filter(|l| {
4388                    l.contains("Manufacturer:")
4389                        || l.contains("Product Name:")
4390                        || l.contains("Version:")
4391                })
4392                .take(6)
4393            {
4394                out.push_str(&format!("  {}\n", line.trim()));
4395            }
4396        }
4397    }
4398
4399    Ok(out.trim_end().to_string())
4400}
4401
4402// ── updates ───────────────────────────────────────────────────────────────────
4403
4404fn inspect_updates() -> Result<String, String> {
4405    let mut out = String::from("Host inspection: updates\n\n");
4406
4407    #[cfg(target_os = "windows")]
4408    {
4409        // Last installed update via COM
4410        let script = r#"
4411try {
4412    $sess = New-Object -ComObject Microsoft.Update.Session
4413    $searcher = $sess.CreateUpdateSearcher()
4414    $count = $searcher.GetTotalHistoryCount()
4415    if ($count -gt 0) {
4416        $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
4417        $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
4418    } else { "NONE|LAST_INSTALL" }
4419} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
4420"#;
4421        if let Ok(o) = Command::new("powershell")
4422            .args(["-NoProfile", "-Command", script])
4423            .output()
4424        {
4425            let raw = String::from_utf8_lossy(&o.stdout);
4426            let text = raw.trim();
4427            if text.starts_with("ERROR:") {
4428                out.push_str("Last update install: (unable to query)\n");
4429            } else if text.contains("NONE") {
4430                out.push_str("Last update install: No update history found\n");
4431            } else {
4432                let date = text.replace("|LAST_INSTALL", "");
4433                out.push_str(&format!("Last update install: {date}\n"));
4434            }
4435        }
4436
4437        // Pending updates count
4438        let pending_script = r#"
4439try {
4440    $sess = New-Object -ComObject Microsoft.Update.Session
4441    $searcher = $sess.CreateUpdateSearcher()
4442    $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
4443    $results.Updates.Count.ToString() + "|PENDING"
4444} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
4445"#;
4446        if let Ok(o) = Command::new("powershell")
4447            .args(["-NoProfile", "-Command", pending_script])
4448            .output()
4449        {
4450            let raw = String::from_utf8_lossy(&o.stdout);
4451            let text = raw.trim();
4452            if text.starts_with("ERROR:") {
4453                out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
4454            } else {
4455                let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
4456                if count == 0 {
4457                    out.push_str("Pending updates: Up to date — no updates waiting\n");
4458                } else if count > 0 {
4459                    out.push_str(&format!("Pending updates: {count} update(s) available\n"));
4460                    out.push_str(
4461                        "  → Open Windows Update (Settings > Windows Update) to install\n",
4462                    );
4463                }
4464            }
4465        }
4466
4467        // Windows Update service state
4468        let svc_script = r#"
4469$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
4470if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
4471"#;
4472        if let Ok(o) = Command::new("powershell")
4473            .args(["-NoProfile", "-Command", svc_script])
4474            .output()
4475        {
4476            let raw = String::from_utf8_lossy(&o.stdout);
4477            let status = raw.trim();
4478            out.push_str(&format!("Windows Update service: {status}\n"));
4479        }
4480    }
4481
4482    #[cfg(not(target_os = "windows"))]
4483    {
4484        let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
4485        let mut found = false;
4486        if let Ok(o) = apt_out {
4487            let text = String::from_utf8_lossy(&o.stdout);
4488            let lines: Vec<&str> = text
4489                .lines()
4490                .filter(|l| l.contains('/') && !l.contains("Listing"))
4491                .collect();
4492            if !lines.is_empty() {
4493                out.push_str(&format!(
4494                    "{} package(s) can be upgraded (apt)\n",
4495                    lines.len()
4496                ));
4497                out.push_str("  → Run: sudo apt upgrade\n");
4498                found = true;
4499            }
4500        }
4501        if !found {
4502            if let Ok(o) = Command::new("dnf")
4503                .args(["check-update", "--quiet"])
4504                .output()
4505            {
4506                let text = String::from_utf8_lossy(&o.stdout);
4507                let count = text
4508                    .lines()
4509                    .filter(|l| !l.is_empty() && !l.starts_with('!'))
4510                    .count();
4511                if count > 0 {
4512                    out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
4513                    out.push_str("  → Run: sudo dnf upgrade\n");
4514                } else {
4515                    out.push_str("System is up to date.\n");
4516                }
4517            } else {
4518                out.push_str("Could not query package manager for updates.\n");
4519            }
4520        }
4521    }
4522
4523    Ok(out.trim_end().to_string())
4524}
4525
4526// ── security ──────────────────────────────────────────────────────────────────
4527
4528fn inspect_security() -> Result<String, String> {
4529    let mut out = String::from("Host inspection: security\n\n");
4530
4531    #[cfg(target_os = "windows")]
4532    {
4533        // Windows Defender status
4534        let defender_script = r#"
4535try {
4536    $status = Get-MpComputerStatus -ErrorAction Stop
4537    "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
4538} catch { "ERROR:" + $_.Exception.Message }
4539"#;
4540        if let Ok(o) = Command::new("powershell")
4541            .args(["-NoProfile", "-Command", defender_script])
4542            .output()
4543        {
4544            let raw = String::from_utf8_lossy(&o.stdout);
4545            let text = raw.trim();
4546            if text.starts_with("ERROR:") {
4547                out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
4548            } else {
4549                let get = |key: &str| -> String {
4550                    text.split('|')
4551                        .find(|s| s.starts_with(key))
4552                        .and_then(|s| s.splitn(2, ':').nth(1))
4553                        .unwrap_or("unknown")
4554                        .to_string()
4555                };
4556                let rtp = get("RTP");
4557                let last_scan = {
4558                    // SCAN field has a colon in the time, so grab everything after "SCAN:"
4559                    text.split('|')
4560                        .find(|s| s.starts_with("SCAN:"))
4561                        .and_then(|s| s.get(5..))
4562                        .unwrap_or("unknown")
4563                        .to_string()
4564                };
4565                let def_ver = get("VER");
4566                let age_days: i64 = get("AGE").parse().unwrap_or(-1);
4567
4568                let rtp_label = if rtp == "True" {
4569                    "ENABLED"
4570                } else {
4571                    "DISABLED [!]"
4572                };
4573                out.push_str(&format!(
4574                    "Windows Defender real-time protection: {rtp_label}\n"
4575                ));
4576                out.push_str(&format!("Last quick scan: {last_scan}\n"));
4577                out.push_str(&format!("Signature version: {def_ver}\n"));
4578                if age_days >= 0 {
4579                    let freshness = if age_days == 0 {
4580                        "up to date".to_string()
4581                    } else if age_days <= 3 {
4582                        format!("{age_days} day(s) old — OK")
4583                    } else if age_days <= 7 {
4584                        format!("{age_days} day(s) old — consider updating")
4585                    } else {
4586                        format!("{age_days} day(s) old — [!] STALE, run Windows Update")
4587                    };
4588                    out.push_str(&format!("Signature age: {freshness}\n"));
4589                }
4590                if rtp != "True" {
4591                    out.push_str(
4592                        "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
4593                    );
4594                    out.push_str(
4595                        "    → Open Windows Security > Virus & threat protection to re-enable.\n",
4596                    );
4597                }
4598            }
4599        }
4600
4601        out.push('\n');
4602
4603        // Windows Firewall state
4604        let fw_script = r#"
4605try {
4606    Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
4607} catch { "ERROR:" + $_.Exception.Message }
4608"#;
4609        if let Ok(o) = Command::new("powershell")
4610            .args(["-NoProfile", "-Command", fw_script])
4611            .output()
4612        {
4613            let raw = String::from_utf8_lossy(&o.stdout);
4614            let text = raw.trim();
4615            if !text.starts_with("ERROR:") && !text.is_empty() {
4616                out.push_str("Windows Firewall:\n");
4617                for line in text.lines() {
4618                    if let Some((name, enabled)) = line.split_once(':') {
4619                        let state = if enabled.trim() == "True" {
4620                            "ON"
4621                        } else {
4622                            "OFF [!]"
4623                        };
4624                        out.push_str(&format!("  {name}: {state}\n"));
4625                    }
4626                }
4627                out.push('\n');
4628            }
4629        }
4630
4631        // Windows activation status
4632        let act_script = r#"
4633try {
4634    $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
4635    if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
4636} catch { "UNKNOWN" }
4637"#;
4638        if let Ok(o) = Command::new("powershell")
4639            .args(["-NoProfile", "-Command", act_script])
4640            .output()
4641        {
4642            let raw = String::from_utf8_lossy(&o.stdout);
4643            match raw.trim() {
4644                "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
4645                "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
4646                _ => out.push_str("Windows activation: Unable to determine\n"),
4647            }
4648        }
4649
4650        // UAC state
4651        let uac_script = r#"
4652$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
4653if ($val -eq 1) { "ON" } else { "OFF" }
4654"#;
4655        if let Ok(o) = Command::new("powershell")
4656            .args(["-NoProfile", "-Command", uac_script])
4657            .output()
4658        {
4659            let raw = String::from_utf8_lossy(&o.stdout);
4660            let state = raw.trim();
4661            let label = if state == "ON" {
4662                "Enabled"
4663            } else {
4664                "DISABLED [!] — recommended to re-enable via secpol.msc"
4665            };
4666            out.push_str(&format!("UAC (User Account Control): {label}\n"));
4667        }
4668    }
4669
4670    #[cfg(not(target_os = "windows"))]
4671    {
4672        if let Ok(o) = Command::new("ufw").arg("status").output() {
4673            let text = String::from_utf8_lossy(&o.stdout);
4674            out.push_str(&format!(
4675                "UFW: {}\n",
4676                text.lines().next().unwrap_or("unknown")
4677            ));
4678        }
4679        if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
4680            if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
4681                out.push_str(&format!("{line}\n"));
4682            }
4683        }
4684    }
4685
4686    Ok(out.trim_end().to_string())
4687}
4688
4689// ── pending_reboot ────────────────────────────────────────────────────────────
4690
4691fn inspect_pending_reboot() -> Result<String, String> {
4692    let mut out = String::from("Host inspection: pending_reboot\n\n");
4693
4694    #[cfg(target_os = "windows")]
4695    {
4696        let script = r#"
4697$reasons = @()
4698if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
4699    $reasons += "Windows Update requires a restart"
4700}
4701if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
4702    $reasons += "Windows component install/update requires a restart"
4703}
4704$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
4705if ($pfro -and $pfro.PendingFileRenameOperations) {
4706    $reasons += "Pending file rename operations (driver or system file replacement)"
4707}
4708if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
4709"#;
4710        let output = Command::new("powershell")
4711            .args(["-NoProfile", "-Command", script])
4712            .output()
4713            .map_err(|e| format!("pending_reboot: {e}"))?;
4714
4715        let raw = String::from_utf8_lossy(&output.stdout);
4716        let text = raw.trim();
4717
4718        if text == "NO_REBOOT_NEEDED" {
4719            out.push_str("No restart required — system is up to date and stable.\n");
4720        } else if text.is_empty() {
4721            out.push_str("Could not determine reboot status.\n");
4722        } else {
4723            out.push_str("[!] A system restart is pending:\n\n");
4724            for reason in text.split("|REASON|") {
4725                out.push_str(&format!("  • {}\n", reason.trim()));
4726            }
4727            out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
4728        }
4729    }
4730
4731    #[cfg(not(target_os = "windows"))]
4732    {
4733        if std::path::Path::new("/var/run/reboot-required").exists() {
4734            out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
4735            if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
4736                out.push_str("Packages requiring restart:\n");
4737                for p in pkgs.lines().take(10) {
4738                    out.push_str(&format!("  • {p}\n"));
4739                }
4740            }
4741        } else {
4742            out.push_str("No restart required.\n");
4743        }
4744    }
4745
4746    Ok(out.trim_end().to_string())
4747}
4748
4749// ── disk_health ───────────────────────────────────────────────────────────────
4750
4751fn inspect_disk_health() -> Result<String, String> {
4752    let mut out = String::from("Host inspection: disk_health\n\n");
4753
4754    #[cfg(target_os = "windows")]
4755    {
4756        let script = r#"
4757try {
4758    $disks = Get-PhysicalDisk -ErrorAction Stop
4759    foreach ($d in $disks) {
4760        $size_gb = [math]::Round($d.Size / 1GB, 0)
4761        $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
4762    }
4763} catch { "ERROR:" + $_.Exception.Message }
4764"#;
4765        let output = Command::new("powershell")
4766            .args(["-NoProfile", "-Command", script])
4767            .output()
4768            .map_err(|e| format!("disk_health: {e}"))?;
4769
4770        let raw = String::from_utf8_lossy(&output.stdout);
4771        let text = raw.trim();
4772
4773        if text.starts_with("ERROR:") {
4774            out.push_str(&format!("Unable to query disk health: {text}\n"));
4775            out.push_str("This may require running as administrator.\n");
4776        } else if text.is_empty() {
4777            out.push_str("No physical disks found.\n");
4778        } else {
4779            out.push_str("Physical Drive Health:\n\n");
4780            for line in text.lines() {
4781                let parts: Vec<&str> = line.splitn(5, '|').collect();
4782                if parts.len() >= 4 {
4783                    let name = parts[0];
4784                    let media = parts[1];
4785                    let size = parts[2];
4786                    let health = parts[3];
4787                    let op_status = parts.get(4).unwrap_or(&"");
4788                    let health_label = match health.trim() {
4789                        "Healthy" => "OK",
4790                        "Warning" => "[!] WARNING",
4791                        "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
4792                        other => other,
4793                    };
4794                    out.push_str(&format!("  {name}\n"));
4795                    out.push_str(&format!("    Type: {media} | Size: {size}\n"));
4796                    out.push_str(&format!("    Health: {health_label}\n"));
4797                    if !op_status.is_empty() {
4798                        out.push_str(&format!("    Status: {op_status}\n"));
4799                    }
4800                    out.push('\n');
4801                }
4802            }
4803        }
4804
4805        // SMART failure prediction (best-effort, may need admin)
4806        let smart_script = r#"
4807try {
4808    Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
4809        ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
4810} catch { "" }
4811"#;
4812        if let Ok(o) = Command::new("powershell")
4813            .args(["-NoProfile", "-Command", smart_script])
4814            .output()
4815        {
4816            let raw2 = String::from_utf8_lossy(&o.stdout);
4817            let text2 = raw2.trim();
4818            if !text2.is_empty() {
4819                let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
4820                if failures.is_empty() {
4821                    out.push_str("SMART failure prediction: No failures predicted\n");
4822                } else {
4823                    out.push_str("[!!] SMART failure predicted on one or more drives:\n");
4824                    for f in failures {
4825                        let name = f.split('|').next().unwrap_or(f);
4826                        out.push_str(&format!("  • {name}\n"));
4827                    }
4828                    out.push_str(
4829                        "\nBack up your data immediately and replace the failing drive.\n",
4830                    );
4831                }
4832            }
4833        }
4834    }
4835
4836    #[cfg(not(target_os = "windows"))]
4837    {
4838        if let Ok(o) = Command::new("lsblk")
4839            .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
4840            .output()
4841        {
4842            let text = String::from_utf8_lossy(&o.stdout);
4843            out.push_str("Block devices:\n");
4844            out.push_str(text.trim());
4845            out.push('\n');
4846        }
4847        if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
4848            let devices = String::from_utf8_lossy(&scan.stdout);
4849            for dev_line in devices.lines().take(4) {
4850                let dev = dev_line.split_whitespace().next().unwrap_or("");
4851                if dev.is_empty() {
4852                    continue;
4853                }
4854                if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
4855                    let health = String::from_utf8_lossy(&o.stdout);
4856                    if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
4857                    {
4858                        out.push_str(&format!("{dev}: {}\n", line.trim()));
4859                    }
4860                }
4861            }
4862        } else {
4863            out.push_str("(install smartmontools for SMART health data)\n");
4864        }
4865    }
4866
4867    Ok(out.trim_end().to_string())
4868}
4869
4870// ── battery ───────────────────────────────────────────────────────────────────
4871
4872fn inspect_battery() -> Result<String, String> {
4873    let mut out = String::from("Host inspection: battery\n\n");
4874
4875    #[cfg(target_os = "windows")]
4876    {
4877        let script = r#"
4878try {
4879    $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
4880    if (-not $bats) { "NO_BATTERY"; exit }
4881    
4882    # Modern Battery Health (Cycle count + Capacity health)
4883    $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
4884    $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue 
4885    $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
4886
4887    foreach ($b in $bats) {
4888        $state = switch ($b.BatteryStatus) {
4889            1 { "Discharging" }
4890            2 { "AC Power (Fully Charged)" }
4891            3 { "AC Power (Charging)" }
4892            default { "Status $($b.BatteryStatus)" }
4893        }
4894        
4895        $cycles = if ($status) { $status.CycleCount } else { "unknown" }
4896        $health = if ($static -and $full) {
4897             [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
4898        } else { "unknown" }
4899
4900        $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
4901    }
4902} catch { "ERROR:" + $_.Exception.Message }
4903"#;
4904        let output = Command::new("powershell")
4905            .args(["-NoProfile", "-Command", script])
4906            .output()
4907            .map_err(|e| format!("battery: {e}"))?;
4908
4909        let raw = String::from_utf8_lossy(&output.stdout);
4910        let text = raw.trim();
4911
4912        if text == "NO_BATTERY" {
4913            out.push_str("No battery detected — desktop or AC-only system.\n");
4914            return Ok(out.trim_end().to_string());
4915        }
4916        if text.starts_with("ERROR:") {
4917            out.push_str(&format!("Unable to query battery: {text}\n"));
4918            return Ok(out.trim_end().to_string());
4919        }
4920
4921        for line in text.lines() {
4922            let parts: Vec<&str> = line.split('|').collect();
4923            if parts.len() == 5 {
4924                let name = parts[0];
4925                let charge: i64 = parts[1].parse().unwrap_or(-1);
4926                let state = parts[2];
4927                let cycles = parts[3];
4928                let health = parts[4];
4929
4930                out.push_str(&format!("Battery: {name}\n"));
4931                if charge >= 0 {
4932                    let bar_filled = (charge as usize * 20) / 100;
4933                    out.push_str(&format!(
4934                        "  Charge: [{}{}] {}%\n",
4935                        "#".repeat(bar_filled),
4936                        ".".repeat(20 - bar_filled),
4937                        charge
4938                    ));
4939                }
4940                out.push_str(&format!("  Status: {state}\n"));
4941                out.push_str(&format!("  Cycles: {cycles}\n"));
4942                out.push_str(&format!(
4943                    "  Health: {health}% (Actual vs Design Capacity)\n\n"
4944                ));
4945            }
4946        }
4947    }
4948
4949    #[cfg(not(target_os = "windows"))]
4950    {
4951        let power_path = std::path::Path::new("/sys/class/power_supply");
4952        let mut found = false;
4953        if power_path.exists() {
4954            if let Ok(entries) = std::fs::read_dir(power_path) {
4955                for entry in entries.flatten() {
4956                    let p = entry.path();
4957                    if let Ok(t) = std::fs::read_to_string(p.join("type")) {
4958                        if t.trim() == "Battery" {
4959                            found = true;
4960                            let name = p
4961                                .file_name()
4962                                .unwrap_or_default()
4963                                .to_string_lossy()
4964                                .to_string();
4965                            out.push_str(&format!("Battery: {name}\n"));
4966                            let read = |f: &str| {
4967                                std::fs::read_to_string(p.join(f))
4968                                    .ok()
4969                                    .map(|s| s.trim().to_string())
4970                            };
4971                            if let Some(cap) = read("capacity") {
4972                                out.push_str(&format!("  Charge: {cap}%\n"));
4973                            }
4974                            if let Some(status) = read("status") {
4975                                out.push_str(&format!("  Status: {status}\n"));
4976                            }
4977                            if let (Some(full), Some(design)) =
4978                                (read("energy_full"), read("energy_full_design"))
4979                            {
4980                                if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
4981                                {
4982                                    if d > 0.0 {
4983                                        out.push_str(&format!(
4984                                            "  Wear level: {:.1}% of design capacity\n",
4985                                            (f / d) * 100.0
4986                                        ));
4987                                    }
4988                                }
4989                            }
4990                        }
4991                    }
4992                }
4993            }
4994        }
4995        if !found {
4996            out.push_str("No battery found.\n");
4997        }
4998    }
4999
5000    Ok(out.trim_end().to_string())
5001}
5002
5003// ── recent_crashes ────────────────────────────────────────────────────────────
5004
5005fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
5006    let mut out = String::from("Host inspection: recent_crashes\n\n");
5007    let n = max_entries.clamp(1, 30);
5008
5009    #[cfg(target_os = "windows")]
5010    {
5011        // BSODs / unexpected shutdowns (EventID 41 = kernel power, 1001 = BugCheck)
5012        let bsod_script = format!(
5013            r#"
5014try {{
5015    $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
5016    if ($events) {{
5017        $events | ForEach-Object {{
5018            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5019        }}
5020    }} else {{ "NO_BSOD" }}
5021}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5022        );
5023
5024        if let Ok(o) = Command::new("powershell")
5025            .args(["-NoProfile", "-Command", &bsod_script])
5026            .output()
5027        {
5028            let raw = String::from_utf8_lossy(&o.stdout);
5029            let text = raw.trim();
5030            if text == "NO_BSOD" {
5031                out.push_str("System crashes (BSOD/kernel): None in recent history\n");
5032            } else if text.starts_with("ERROR:") {
5033                out.push_str("System crashes: unable to query\n");
5034            } else {
5035                out.push_str("System crashes / unexpected shutdowns:\n");
5036                for line in text.lines() {
5037                    let parts: Vec<&str> = line.splitn(3, '|').collect();
5038                    if parts.len() >= 3 {
5039                        let time = parts[0];
5040                        let id = parts[1];
5041                        let msg = parts[2];
5042                        let label = if id == "41" {
5043                            "Unexpected shutdown"
5044                        } else {
5045                            "BSOD (BugCheck)"
5046                        };
5047                        out.push_str(&format!("  [{time}] {label}: {msg}\n"));
5048                    }
5049                }
5050                out.push('\n');
5051            }
5052        }
5053
5054        // Application crashes (EventID 1000 = app crash, 1002 = app hang)
5055        let app_script = format!(
5056            r#"
5057try {{
5058    $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
5059    if ($crashes) {{
5060        $crashes | ForEach-Object {{
5061            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5062        }}
5063    }} else {{ "NO_CRASHES" }}
5064}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
5065        );
5066
5067        if let Ok(o) = Command::new("powershell")
5068            .args(["-NoProfile", "-Command", &app_script])
5069            .output()
5070        {
5071            let raw = String::from_utf8_lossy(&o.stdout);
5072            let text = raw.trim();
5073            if text == "NO_CRASHES" {
5074                out.push_str("Application crashes: None in recent history\n");
5075            } else if text.starts_with("ERROR_APP:") {
5076                out.push_str("Application crashes: unable to query\n");
5077            } else {
5078                out.push_str("Application crashes:\n");
5079                for line in text.lines().take(n) {
5080                    let parts: Vec<&str> = line.splitn(2, '|').collect();
5081                    if parts.len() >= 2 {
5082                        out.push_str(&format!("  [{}] {}\n", parts[0], parts[1]));
5083                    }
5084                }
5085            }
5086        }
5087    }
5088
5089    #[cfg(not(target_os = "windows"))]
5090    {
5091        let n_str = n.to_string();
5092        if let Ok(o) = Command::new("journalctl")
5093            .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
5094            .output()
5095        {
5096            let text = String::from_utf8_lossy(&o.stdout);
5097            let trimmed = text.trim();
5098            if trimmed.is_empty() || trimmed.contains("No entries") {
5099                out.push_str("No kernel panics or critical crashes found.\n");
5100            } else {
5101                out.push_str("Kernel critical events:\n");
5102                out.push_str(trimmed);
5103                out.push('\n');
5104            }
5105        }
5106        if let Ok(o) = Command::new("coredumpctl")
5107            .args(["list", "--no-pager"])
5108            .output()
5109        {
5110            let text = String::from_utf8_lossy(&o.stdout);
5111            let count = text
5112                .lines()
5113                .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
5114                .count();
5115            if count > 0 {
5116                out.push_str(&format!(
5117                    "\nCore dumps on file: {count}\n  → Run: coredumpctl list\n"
5118                ));
5119            }
5120        }
5121    }
5122
5123    Ok(out.trim_end().to_string())
5124}
5125
5126// ── scheduled_tasks ───────────────────────────────────────────────────────────
5127
5128fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
5129    let mut out = String::from("Host inspection: scheduled_tasks\n\n");
5130    let n = max_entries.clamp(1, 30);
5131
5132    #[cfg(target_os = "windows")]
5133    {
5134        let script = format!(
5135            r#"
5136try {{
5137    $tasks = Get-ScheduledTask -ErrorAction Stop |
5138        Where-Object {{ $_.State -ne 'Disabled' }} |
5139        ForEach-Object {{
5140            $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
5141            $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
5142                $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
5143            }} else {{ "never" }}
5144            $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
5145            $exec = ($_.Actions | Select-Object -First 1).Execute
5146            if (-not $exec) {{ $exec = "(no exec)" }}
5147            $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
5148        }}
5149    $tasks | Select-Object -First {n}
5150}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5151        );
5152
5153        let output = Command::new("powershell")
5154            .args(["-NoProfile", "-Command", &script])
5155            .output()
5156            .map_err(|e| format!("scheduled_tasks: {e}"))?;
5157
5158        let raw = String::from_utf8_lossy(&output.stdout);
5159        let text = raw.trim();
5160
5161        if text.starts_with("ERROR:") {
5162            out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
5163        } else if text.is_empty() {
5164            out.push_str("No active scheduled tasks found.\n");
5165        } else {
5166            out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
5167            for line in text.lines() {
5168                let parts: Vec<&str> = line.splitn(6, '|').collect();
5169                if parts.len() >= 5 {
5170                    let name = parts[0];
5171                    let path = parts[1];
5172                    let state = parts[2];
5173                    let last = parts[3];
5174                    let res = parts[4];
5175                    let exec = parts.get(5).unwrap_or(&"").trim();
5176                    let display_path = path.trim_matches('\\');
5177                    let display_path = if display_path.is_empty() {
5178                        "Root"
5179                    } else {
5180                        display_path
5181                    };
5182                    out.push_str(&format!("  {name} [{display_path}]\n"));
5183                    out.push_str(&format!(
5184                        "    State: {state} | Last run: {last} | Result: {res}\n"
5185                    ));
5186                    if !exec.is_empty() && exec != "(no exec)" {
5187                        let short = if exec.len() > 80 { &exec[..80] } else { exec };
5188                        out.push_str(&format!("    Runs: {short}\n"));
5189                    }
5190                }
5191            }
5192        }
5193    }
5194
5195    #[cfg(not(target_os = "windows"))]
5196    {
5197        if let Ok(o) = Command::new("systemctl")
5198            .args(["list-timers", "--no-pager", "--all"])
5199            .output()
5200        {
5201            let text = String::from_utf8_lossy(&o.stdout);
5202            out.push_str("Systemd timers:\n");
5203            for l in text
5204                .lines()
5205                .filter(|l| {
5206                    !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
5207                })
5208                .take(n)
5209            {
5210                out.push_str(&format!("  {l}\n"));
5211            }
5212            out.push('\n');
5213        }
5214        if let Ok(o) = Command::new("crontab").arg("-l").output() {
5215            let text = String::from_utf8_lossy(&o.stdout);
5216            let jobs: Vec<&str> = text
5217                .lines()
5218                .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
5219                .collect();
5220            if !jobs.is_empty() {
5221                out.push_str("User crontab:\n");
5222                for j in jobs.iter().take(n) {
5223                    out.push_str(&format!("  {j}\n"));
5224                }
5225            }
5226        }
5227    }
5228
5229    Ok(out.trim_end().to_string())
5230}
5231
5232// ── dev_conflicts ─────────────────────────────────────────────────────────────
5233
5234fn inspect_dev_conflicts() -> Result<String, String> {
5235    let mut out = String::from("Host inspection: dev_conflicts\n\n");
5236    let mut conflicts: Vec<String> = Vec::new();
5237    let mut notes: Vec<String> = Vec::new();
5238
5239    // ── Node.js / version managers ────────────────────────────────────────────
5240    {
5241        let node_ver = Command::new("node")
5242            .arg("--version")
5243            .output()
5244            .ok()
5245            .and_then(|o| String::from_utf8(o.stdout).ok())
5246            .map(|s| s.trim().to_string());
5247        let nvm_active = Command::new("nvm")
5248            .arg("current")
5249            .output()
5250            .ok()
5251            .and_then(|o| String::from_utf8(o.stdout).ok())
5252            .map(|s| s.trim().to_string())
5253            .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
5254        let fnm_active = Command::new("fnm")
5255            .arg("current")
5256            .output()
5257            .ok()
5258            .and_then(|o| String::from_utf8(o.stdout).ok())
5259            .map(|s| s.trim().to_string())
5260            .filter(|s| !s.is_empty() && !s.contains("none"));
5261        let volta_active = Command::new("volta")
5262            .args(["which", "node"])
5263            .output()
5264            .ok()
5265            .and_then(|o| String::from_utf8(o.stdout).ok())
5266            .map(|s| s.trim().to_string())
5267            .filter(|s| !s.is_empty());
5268
5269        out.push_str("Node.js:\n");
5270        if let Some(ref v) = node_ver {
5271            out.push_str(&format!("  Active: {v}\n"));
5272        } else {
5273            out.push_str("  Not installed\n");
5274        }
5275        let managers: Vec<&str> = [
5276            nvm_active.as_deref(),
5277            fnm_active.as_deref(),
5278            volta_active.as_deref(),
5279        ]
5280        .iter()
5281        .filter_map(|x| *x)
5282        .collect();
5283        if managers.len() > 1 {
5284            conflicts.push(format!(
5285                "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
5286            ));
5287        } else if !managers.is_empty() {
5288            out.push_str(&format!("  Version manager: {}\n", managers[0]));
5289        }
5290        out.push('\n');
5291    }
5292
5293    // ── Python ────────────────────────────────────────────────────────────────
5294    {
5295        let py3 = Command::new("python3")
5296            .arg("--version")
5297            .output()
5298            .ok()
5299            .and_then(|o| {
5300                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5301                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5302                let v = if stdout.is_empty() { stderr } else { stdout };
5303                if v.is_empty() {
5304                    None
5305                } else {
5306                    Some(v)
5307                }
5308            });
5309        let py = Command::new("python")
5310            .arg("--version")
5311            .output()
5312            .ok()
5313            .and_then(|o| {
5314                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5315                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5316                let v = if stdout.is_empty() { stderr } else { stdout };
5317                if v.is_empty() {
5318                    None
5319                } else {
5320                    Some(v)
5321                }
5322            });
5323        let pyenv = Command::new("pyenv")
5324            .arg("version")
5325            .output()
5326            .ok()
5327            .and_then(|o| String::from_utf8(o.stdout).ok())
5328            .map(|s| s.trim().to_string())
5329            .filter(|s| !s.is_empty());
5330        let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
5331
5332        out.push_str("Python:\n");
5333        match (&py3, &py) {
5334            (Some(v3), Some(v)) if v3 != v => {
5335                out.push_str(&format!("  python3: {v3}\n  python:  {v}\n"));
5336                if v.contains("2.") {
5337                    conflicts.push(
5338                        "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
5339                    );
5340                } else {
5341                    notes.push(
5342                        "python and python3 resolve to different minor versions.".to_string(),
5343                    );
5344                }
5345            }
5346            (Some(v3), None) => out.push_str(&format!("  python3: {v3}\n")),
5347            (None, Some(v)) => out.push_str(&format!("  python: {v}\n")),
5348            (Some(v3), Some(_)) => out.push_str(&format!("  {v3}\n")),
5349            (None, None) => out.push_str("  Not installed\n"),
5350        }
5351        if let Some(ref pe) = pyenv {
5352            out.push_str(&format!("  pyenv: {pe}\n"));
5353        }
5354        if let Some(env) = conda_env {
5355            if env == "base" {
5356                notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
5357            } else {
5358                out.push_str(&format!("  conda env: {env}\n"));
5359            }
5360        }
5361        out.push('\n');
5362    }
5363
5364    // ── Rust / Cargo ──────────────────────────────────────────────────────────
5365    {
5366        let toolchain = Command::new("rustup")
5367            .args(["show", "active-toolchain"])
5368            .output()
5369            .ok()
5370            .and_then(|o| String::from_utf8(o.stdout).ok())
5371            .map(|s| s.trim().to_string())
5372            .filter(|s| !s.is_empty());
5373        let cargo_ver = Command::new("cargo")
5374            .arg("--version")
5375            .output()
5376            .ok()
5377            .and_then(|o| String::from_utf8(o.stdout).ok())
5378            .map(|s| s.trim().to_string());
5379        let rustc_ver = Command::new("rustc")
5380            .arg("--version")
5381            .output()
5382            .ok()
5383            .and_then(|o| String::from_utf8(o.stdout).ok())
5384            .map(|s| s.trim().to_string());
5385
5386        out.push_str("Rust:\n");
5387        if let Some(ref t) = toolchain {
5388            out.push_str(&format!("  Active toolchain: {t}\n"));
5389        }
5390        if let Some(ref c) = cargo_ver {
5391            out.push_str(&format!("  {c}\n"));
5392        }
5393        if let Some(ref r) = rustc_ver {
5394            out.push_str(&format!("  {r}\n"));
5395        }
5396        if cargo_ver.is_none() && rustc_ver.is_none() {
5397            out.push_str("  Not installed\n");
5398        }
5399
5400        // Detect system rust that might shadow rustup
5401        #[cfg(not(target_os = "windows"))]
5402        if let Ok(o) = Command::new("which").arg("rustc").output() {
5403            let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
5404            if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
5405                conflicts.push(format!(
5406                    "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
5407                ));
5408            }
5409        }
5410        out.push('\n');
5411    }
5412
5413    // ── Git ───────────────────────────────────────────────────────────────────
5414    {
5415        let git_ver = Command::new("git")
5416            .arg("--version")
5417            .output()
5418            .ok()
5419            .and_then(|o| String::from_utf8(o.stdout).ok())
5420            .map(|s| s.trim().to_string());
5421        out.push_str("Git:\n");
5422        if let Some(ref v) = git_ver {
5423            out.push_str(&format!("  {v}\n"));
5424            let email = Command::new("git")
5425                .args(["config", "--global", "user.email"])
5426                .output()
5427                .ok()
5428                .and_then(|o| String::from_utf8(o.stdout).ok())
5429                .map(|s| s.trim().to_string());
5430            if let Some(ref e) = email {
5431                if e.is_empty() {
5432                    notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
5433                } else {
5434                    out.push_str(&format!("  user.email: {e}\n"));
5435                }
5436            }
5437            let gpg_sign = Command::new("git")
5438                .args(["config", "--global", "commit.gpgsign"])
5439                .output()
5440                .ok()
5441                .and_then(|o| String::from_utf8(o.stdout).ok())
5442                .map(|s| s.trim().to_string());
5443            if gpg_sign.as_deref() == Some("true") {
5444                let key = Command::new("git")
5445                    .args(["config", "--global", "user.signingkey"])
5446                    .output()
5447                    .ok()
5448                    .and_then(|o| String::from_utf8(o.stdout).ok())
5449                    .map(|s| s.trim().to_string());
5450                if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
5451                    conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
5452                }
5453            }
5454        } else {
5455            out.push_str("  Not installed\n");
5456        }
5457        out.push('\n');
5458    }
5459
5460    // ── PATH duplicates ───────────────────────────────────────────────────────
5461    {
5462        let path_env = std::env::var("PATH").unwrap_or_default();
5463        let sep = if cfg!(windows) { ';' } else { ':' };
5464        let mut seen = HashSet::new();
5465        let mut dupes: Vec<String> = Vec::new();
5466        for p in path_env.split(sep) {
5467            let norm = p.trim().to_lowercase();
5468            if !norm.is_empty() && !seen.insert(norm) {
5469                dupes.push(p.to_string());
5470            }
5471        }
5472        if !dupes.is_empty() {
5473            let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
5474            notes.push(format!(
5475                "Duplicate PATH entries: {} {}",
5476                shown.join(", "),
5477                if dupes.len() > 3 {
5478                    format!("+{} more", dupes.len() - 3)
5479                } else {
5480                    String::new()
5481                }
5482            ));
5483        }
5484    }
5485
5486    // ── Summary ───────────────────────────────────────────────────────────────
5487    if conflicts.is_empty() && notes.is_empty() {
5488        out.push_str("No conflicts detected — dev environment looks clean.\n");
5489    } else {
5490        if !conflicts.is_empty() {
5491            out.push_str("CONFLICTS:\n");
5492            for c in &conflicts {
5493                out.push_str(&format!("  [!] {c}\n"));
5494            }
5495            out.push('\n');
5496        }
5497        if !notes.is_empty() {
5498            out.push_str("NOTES:\n");
5499            for n in &notes {
5500                out.push_str(&format!("  [-] {n}\n"));
5501            }
5502        }
5503    }
5504
5505    Ok(out.trim_end().to_string())
5506}
5507
5508// ── connectivity ──────────────────────────────────────────────────────────────
5509
5510fn inspect_connectivity() -> Result<String, String> {
5511    let mut out = String::from("Host inspection: connectivity\n\n");
5512
5513    #[cfg(target_os = "windows")]
5514    {
5515        let inet_script = r#"
5516try {
5517    $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
5518    if ($r) { "REACHABLE" } else { "UNREACHABLE" }
5519} catch { "ERROR:" + $_.Exception.Message }
5520"#;
5521        if let Ok(o) = Command::new("powershell")
5522            .args(["-NoProfile", "-Command", inet_script])
5523            .output()
5524        {
5525            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5526            match text.as_str() {
5527                "REACHABLE" => out.push_str("Internet: reachable\n"),
5528                "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
5529                _ => out.push_str(&format!(
5530                    "Internet: {}\n",
5531                    text.trim_start_matches("ERROR:").trim()
5532                )),
5533            }
5534        }
5535
5536        let dns_script = r#"
5537try {
5538    Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
5539    "DNS:ok"
5540} catch { "DNS:fail:" + $_.Exception.Message }
5541"#;
5542        if let Ok(o) = Command::new("powershell")
5543            .args(["-NoProfile", "-Command", dns_script])
5544            .output()
5545        {
5546            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5547            if text == "DNS:ok" {
5548                out.push_str("DNS: resolving correctly\n");
5549            } else {
5550                let detail = text.trim_start_matches("DNS:fail:").trim();
5551                out.push_str(&format!("DNS: failed — {}\n", detail));
5552            }
5553        }
5554
5555        let gw_script = r#"
5556(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
5557"#;
5558        if let Ok(o) = Command::new("powershell")
5559            .args(["-NoProfile", "-Command", gw_script])
5560            .output()
5561        {
5562            let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
5563            if !gw.is_empty() && gw != "0.0.0.0" {
5564                out.push_str(&format!("Default gateway: {}\n", gw));
5565            }
5566        }
5567    }
5568
5569    #[cfg(not(target_os = "windows"))]
5570    {
5571        let reachable = Command::new("ping")
5572            .args(["-c", "1", "-W", "2", "8.8.8.8"])
5573            .output()
5574            .map(|o| o.status.success())
5575            .unwrap_or(false);
5576        out.push_str(if reachable {
5577            "Internet: reachable\n"
5578        } else {
5579            "Internet: unreachable\n"
5580        });
5581        let dns_ok = Command::new("getent")
5582            .args(["hosts", "dns.google"])
5583            .output()
5584            .map(|o| o.status.success())
5585            .unwrap_or(false);
5586        out.push_str(if dns_ok {
5587            "DNS: resolving correctly\n"
5588        } else {
5589            "DNS: failed\n"
5590        });
5591        if let Ok(o) = Command::new("ip")
5592            .args(["route", "show", "default"])
5593            .output()
5594        {
5595            let text = String::from_utf8_lossy(&o.stdout);
5596            if let Some(line) = text.lines().next() {
5597                out.push_str(&format!("Default gateway: {}\n", line.trim()));
5598            }
5599        }
5600    }
5601
5602    Ok(out.trim_end().to_string())
5603}
5604
5605// ── wifi ──────────────────────────────────────────────────────────────────────
5606
5607fn inspect_wifi() -> Result<String, String> {
5608    let mut out = String::from("Host inspection: wifi\n\n");
5609
5610    #[cfg(target_os = "windows")]
5611    {
5612        let output = Command::new("netsh")
5613            .args(["wlan", "show", "interfaces"])
5614            .output()
5615            .map_err(|e| format!("wifi: {e}"))?;
5616        let text = String::from_utf8_lossy(&output.stdout).to_string();
5617
5618        if text.contains("There is no wireless interface") || text.trim().is_empty() {
5619            out.push_str("No wireless interface detected on this machine.\n");
5620            return Ok(out.trim_end().to_string());
5621        }
5622
5623        let fields = [
5624            ("SSID", "SSID"),
5625            ("State", "State"),
5626            ("Signal", "Signal"),
5627            ("Radio type", "Radio type"),
5628            ("Channel", "Channel"),
5629            ("Receive rate (Mbps)", "Download speed (Mbps)"),
5630            ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
5631            ("Authentication", "Authentication"),
5632            ("Network type", "Network type"),
5633        ];
5634
5635        let mut any = false;
5636        for line in text.lines() {
5637            let trimmed = line.trim();
5638            for (key, label) in &fields {
5639                if trimmed.starts_with(key) && trimmed.contains(':') {
5640                    let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
5641                    if !val.is_empty() {
5642                        out.push_str(&format!("  {label}: {val}\n"));
5643                        any = true;
5644                    }
5645                }
5646            }
5647        }
5648        if !any {
5649            out.push_str("  (Wi-Fi adapter disconnected or no active connection)\n");
5650        }
5651    }
5652
5653    #[cfg(not(target_os = "windows"))]
5654    {
5655        if let Ok(o) = Command::new("nmcli")
5656            .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
5657            .output()
5658        {
5659            let text = String::from_utf8_lossy(&o.stdout).to_string();
5660            let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
5661            if lines.is_empty() {
5662                out.push_str("No Wi-Fi devices found.\n");
5663            } else {
5664                for l in lines {
5665                    out.push_str(&format!("  {l}\n"));
5666                }
5667            }
5668        } else if let Ok(o) = Command::new("iwconfig").output() {
5669            let text = String::from_utf8_lossy(&o.stdout).to_string();
5670            if !text.trim().is_empty() {
5671                out.push_str(text.trim());
5672                out.push('\n');
5673            }
5674        } else {
5675            out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
5676        }
5677    }
5678
5679    Ok(out.trim_end().to_string())
5680}
5681
5682// ── connections ───────────────────────────────────────────────────────────────
5683
5684fn inspect_connections(max_entries: usize) -> Result<String, String> {
5685    let mut out = String::from("Host inspection: connections\n\n");
5686    let n = max_entries.clamp(1, 25);
5687
5688    #[cfg(target_os = "windows")]
5689    {
5690        let script = format!(
5691            r#"
5692try {{
5693    $procs = @{{}}
5694    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
5695    $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
5696        Sort-Object OwningProcess
5697    "TOTAL:" + $all.Count
5698    $all | Select-Object -First {n} | ForEach-Object {{
5699        $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
5700        $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
5701    }}
5702}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5703        );
5704
5705        let output = Command::new("powershell")
5706            .args(["-NoProfile", "-Command", &script])
5707            .output()
5708            .map_err(|e| format!("connections: {e}"))?;
5709
5710        let raw = String::from_utf8_lossy(&output.stdout);
5711        let text = raw.trim();
5712
5713        if text.starts_with("ERROR:") {
5714            out.push_str(&format!("Unable to query connections: {text}\n"));
5715        } else {
5716            let mut total = 0usize;
5717            let mut rows = Vec::new();
5718            for line in text.lines() {
5719                if let Some(rest) = line.strip_prefix("TOTAL:") {
5720                    total = rest.trim().parse().unwrap_or(0);
5721                } else {
5722                    rows.push(line);
5723                }
5724            }
5725            out.push_str(&format!("Established TCP connections: {total}\n\n"));
5726            for row in &rows {
5727                let parts: Vec<&str> = row.splitn(4, '|').collect();
5728                if parts.len() == 4 {
5729                    out.push_str(&format!(
5730                        "  {:<15} (pid {:<5}) | {} → {}\n",
5731                        parts[0], parts[1], parts[2], parts[3]
5732                    ));
5733                }
5734            }
5735            if total > n {
5736                out.push_str(&format!(
5737                    "\n  ... {} more connections not shown\n",
5738                    total.saturating_sub(n)
5739                ));
5740            }
5741        }
5742    }
5743
5744    #[cfg(not(target_os = "windows"))]
5745    {
5746        if let Ok(o) = Command::new("ss")
5747            .args(["-tnp", "state", "established"])
5748            .output()
5749        {
5750            let text = String::from_utf8_lossy(&o.stdout);
5751            let lines: Vec<&str> = text
5752                .lines()
5753                .skip(1)
5754                .filter(|l| !l.trim().is_empty())
5755                .collect();
5756            out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
5757            for line in lines.iter().take(n) {
5758                out.push_str(&format!("  {}\n", line.trim()));
5759            }
5760            if lines.len() > n {
5761                out.push_str(&format!("\n  ... {} more not shown\n", lines.len() - n));
5762            }
5763        } else {
5764            out.push_str("ss not available — install iproute2\n");
5765        }
5766    }
5767
5768    Ok(out.trim_end().to_string())
5769}
5770
5771// ── vpn ───────────────────────────────────────────────────────────────────────
5772
5773fn inspect_vpn() -> Result<String, String> {
5774    let mut out = String::from("Host inspection: vpn\n\n");
5775
5776    #[cfg(target_os = "windows")]
5777    {
5778        let script = r#"
5779try {
5780    $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
5781        $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
5782        $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
5783    }
5784    if ($vpn) {
5785        foreach ($a in $vpn) {
5786            $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
5787        }
5788    } else { "NONE" }
5789} catch { "ERROR:" + $_.Exception.Message }
5790"#;
5791        let output = Command::new("powershell")
5792            .args(["-NoProfile", "-Command", script])
5793            .output()
5794            .map_err(|e| format!("vpn: {e}"))?;
5795
5796        let raw = String::from_utf8_lossy(&output.stdout);
5797        let text = raw.trim();
5798
5799        if text == "NONE" {
5800            out.push_str("No VPN adapters detected — no active VPN connection found.\n");
5801        } else if text.starts_with("ERROR:") {
5802            out.push_str(&format!("Unable to query adapters: {text}\n"));
5803        } else {
5804            out.push_str("VPN adapters:\n\n");
5805            for line in text.lines() {
5806                let parts: Vec<&str> = line.splitn(4, '|').collect();
5807                if parts.len() >= 3 {
5808                    let name = parts[0];
5809                    let desc = parts[1];
5810                    let status = parts[2];
5811                    let media = parts.get(3).unwrap_or(&"unknown");
5812                    let label = if status.trim() == "Up" {
5813                        "CONNECTED"
5814                    } else {
5815                        "disconnected"
5816                    };
5817                    out.push_str(&format!(
5818                        "  {name} [{label}]\n    {desc}\n    Status: {status} | Media: {media}\n\n"
5819                    ));
5820                }
5821            }
5822        }
5823
5824        // Windows built-in VPN connections
5825        let ras_script = r#"
5826try {
5827    $c = Get-VpnConnection -ErrorAction Stop
5828    if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
5829    else { "NO_RAS" }
5830} catch { "NO_RAS" }
5831"#;
5832        if let Ok(o) = Command::new("powershell")
5833            .args(["-NoProfile", "-Command", ras_script])
5834            .output()
5835        {
5836            let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
5837            if t != "NO_RAS" && !t.is_empty() {
5838                out.push_str("Windows VPN connections:\n");
5839                for line in t.lines() {
5840                    let parts: Vec<&str> = line.splitn(3, '|').collect();
5841                    if parts.len() >= 2 {
5842                        let name = parts[0];
5843                        let status = parts[1];
5844                        let server = parts.get(2).unwrap_or(&"");
5845                        out.push_str(&format!("  {name} → {server} [{status}]\n"));
5846                    }
5847                }
5848            }
5849        }
5850    }
5851
5852    #[cfg(not(target_os = "windows"))]
5853    {
5854        if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
5855            let text = String::from_utf8_lossy(&o.stdout);
5856            let vpn_ifaces: Vec<&str> = text
5857                .lines()
5858                .filter(|l| {
5859                    l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
5860                })
5861                .collect();
5862            if vpn_ifaces.is_empty() {
5863                out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
5864            } else {
5865                out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
5866                for l in vpn_ifaces {
5867                    out.push_str(&format!("  {}\n", l.trim()));
5868                }
5869            }
5870        }
5871    }
5872
5873    Ok(out.trim_end().to_string())
5874}
5875
5876// ── proxy ─────────────────────────────────────────────────────────────────────
5877
5878fn inspect_proxy() -> Result<String, String> {
5879    let mut out = String::from("Host inspection: proxy\n\n");
5880
5881    #[cfg(target_os = "windows")]
5882    {
5883        let script = r#"
5884$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
5885if ($ie) {
5886    "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
5887} else { "NONE" }
5888"#;
5889        if let Ok(o) = Command::new("powershell")
5890            .args(["-NoProfile", "-Command", script])
5891            .output()
5892        {
5893            let raw = String::from_utf8_lossy(&o.stdout);
5894            let text = raw.trim();
5895            if text != "NONE" && !text.is_empty() {
5896                let get = |key: &str| -> &str {
5897                    text.split('|')
5898                        .find(|s| s.starts_with(key))
5899                        .and_then(|s| s.splitn(2, ':').nth(1))
5900                        .unwrap_or("")
5901                };
5902                let enabled = get("ENABLE");
5903                let server = get("SERVER");
5904                let overrides = get("OVERRIDE");
5905                out.push_str("WinINET / IE proxy:\n");
5906                out.push_str(&format!(
5907                    "  Enabled: {}\n",
5908                    if enabled == "1" { "yes" } else { "no" }
5909                ));
5910                if !server.is_empty() && server != "None" {
5911                    out.push_str(&format!("  Proxy server: {server}\n"));
5912                }
5913                if !overrides.is_empty() && overrides != "None" {
5914                    out.push_str(&format!("  Bypass list: {overrides}\n"));
5915                }
5916                out.push('\n');
5917            }
5918        }
5919
5920        if let Ok(o) = Command::new("netsh")
5921            .args(["winhttp", "show", "proxy"])
5922            .output()
5923        {
5924            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5925            out.push_str("WinHTTP proxy:\n");
5926            for line in text.lines() {
5927                let l = line.trim();
5928                if !l.is_empty() {
5929                    out.push_str(&format!("  {l}\n"));
5930                }
5931            }
5932            out.push('\n');
5933        }
5934
5935        let mut env_found = false;
5936        for var in &[
5937            "http_proxy",
5938            "https_proxy",
5939            "HTTP_PROXY",
5940            "HTTPS_PROXY",
5941            "no_proxy",
5942            "NO_PROXY",
5943        ] {
5944            if let Ok(val) = std::env::var(var) {
5945                if !env_found {
5946                    out.push_str("Environment proxy variables:\n");
5947                    env_found = true;
5948                }
5949                out.push_str(&format!("  {var}: {val}\n"));
5950            }
5951        }
5952        if !env_found {
5953            out.push_str("No proxy environment variables set.\n");
5954        }
5955    }
5956
5957    #[cfg(not(target_os = "windows"))]
5958    {
5959        let mut found = false;
5960        for var in &[
5961            "http_proxy",
5962            "https_proxy",
5963            "HTTP_PROXY",
5964            "HTTPS_PROXY",
5965            "no_proxy",
5966            "NO_PROXY",
5967            "ALL_PROXY",
5968            "all_proxy",
5969        ] {
5970            if let Ok(val) = std::env::var(var) {
5971                if !found {
5972                    out.push_str("Proxy environment variables:\n");
5973                    found = true;
5974                }
5975                out.push_str(&format!("  {var}: {val}\n"));
5976            }
5977        }
5978        if !found {
5979            out.push_str("No proxy environment variables set.\n");
5980        }
5981        if let Ok(content) = std::fs::read_to_string("/etc/environment") {
5982            let proxy_lines: Vec<&str> = content
5983                .lines()
5984                .filter(|l| l.to_lowercase().contains("proxy"))
5985                .collect();
5986            if !proxy_lines.is_empty() {
5987                out.push_str("\nSystem proxy (/etc/environment):\n");
5988                for l in proxy_lines {
5989                    out.push_str(&format!("  {l}\n"));
5990                }
5991            }
5992        }
5993    }
5994
5995    Ok(out.trim_end().to_string())
5996}
5997
5998// ── firewall_rules ────────────────────────────────────────────────────────────
5999
6000fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
6001    let mut out = String::from("Host inspection: firewall_rules\n\n");
6002    let n = max_entries.clamp(1, 20);
6003
6004    #[cfg(target_os = "windows")]
6005    {
6006        let script = format!(
6007            r#"
6008try {{
6009    $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
6010        Where-Object {{
6011            $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
6012            $_.Owner -eq $null
6013        }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
6014    "TOTAL:" + $rules.Count
6015    $rules | ForEach-Object {{
6016        $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
6017        $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
6018        $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
6019    }}
6020}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6021        );
6022
6023        let output = Command::new("powershell")
6024            .args(["-NoProfile", "-Command", &script])
6025            .output()
6026            .map_err(|e| format!("firewall_rules: {e}"))?;
6027
6028        let raw = String::from_utf8_lossy(&output.stdout);
6029        let text = raw.trim();
6030
6031        if text.starts_with("ERROR:") {
6032            out.push_str(&format!(
6033                "Unable to query firewall rules: {}\n",
6034                text.trim_start_matches("ERROR:").trim()
6035            ));
6036            out.push_str("This query may require running as administrator.\n");
6037        } else if text.is_empty() {
6038            out.push_str("No non-default enabled firewall rules found.\n");
6039        } else {
6040            let mut total = 0usize;
6041            for line in text.lines() {
6042                if let Some(rest) = line.strip_prefix("TOTAL:") {
6043                    total = rest.trim().parse().unwrap_or(0);
6044                    out.push_str(&format!(
6045                        "Non-default enabled rules (showing up to {n}):\n\n"
6046                    ));
6047                } else {
6048                    let parts: Vec<&str> = line.splitn(4, '|').collect();
6049                    if parts.len() >= 3 {
6050                        let name = parts[0];
6051                        let dir = parts[1];
6052                        let action = parts[2];
6053                        let profile = parts.get(3).unwrap_or(&"Any");
6054                        let icon = if action == "Block" { "[!]" } else { "   " };
6055                        out.push_str(&format!(
6056                            "  {icon} [{dir}] {action}: {name} (profile: {profile})\n"
6057                        ));
6058                    }
6059                }
6060            }
6061            if total == 0 {
6062                out.push_str("No non-default enabled rules found.\n");
6063            }
6064        }
6065    }
6066
6067    #[cfg(not(target_os = "windows"))]
6068    {
6069        if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
6070            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6071            if !text.is_empty() {
6072                out.push_str(&text);
6073                out.push('\n');
6074            }
6075        } else if let Ok(o) = Command::new("iptables")
6076            .args(["-L", "-n", "--line-numbers"])
6077            .output()
6078        {
6079            let text = String::from_utf8_lossy(&o.stdout);
6080            for l in text.lines().take(n * 2) {
6081                out.push_str(&format!("  {l}\n"));
6082            }
6083        } else {
6084            out.push_str("ufw and iptables not available or insufficient permissions.\n");
6085        }
6086    }
6087
6088    Ok(out.trim_end().to_string())
6089}
6090
6091// ── traceroute ────────────────────────────────────────────────────────────────
6092
6093fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
6094    let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
6095    let hops = max_entries.clamp(5, 30);
6096
6097    #[cfg(target_os = "windows")]
6098    {
6099        let output = Command::new("tracert")
6100            .args(["-d", "-h", &hops.to_string(), host])
6101            .output()
6102            .map_err(|e| format!("tracert: {e}"))?;
6103        let raw = String::from_utf8_lossy(&output.stdout);
6104        let mut hop_count = 0usize;
6105        for line in raw.lines() {
6106            let trimmed = line.trim();
6107            if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
6108                hop_count += 1;
6109                out.push_str(&format!("  {trimmed}\n"));
6110            } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
6111                out.push_str(&format!("{trimmed}\n"));
6112            }
6113        }
6114        if hop_count == 0 {
6115            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6116        }
6117    }
6118
6119    #[cfg(not(target_os = "windows"))]
6120    {
6121        let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
6122            || std::path::Path::new("/usr/sbin/traceroute").exists()
6123        {
6124            "traceroute"
6125        } else {
6126            "tracepath"
6127        };
6128        let output = Command::new(cmd)
6129            .args(["-m", &hops.to_string(), "-n", host])
6130            .output()
6131            .map_err(|e| format!("{cmd}: {e}"))?;
6132        let raw = String::from_utf8_lossy(&output.stdout);
6133        let mut hop_count = 0usize;
6134        for line in raw.lines().take(hops + 2) {
6135            let trimmed = line.trim();
6136            if !trimmed.is_empty() {
6137                hop_count += 1;
6138                out.push_str(&format!("  {trimmed}\n"));
6139            }
6140        }
6141        if hop_count == 0 {
6142            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6143        }
6144    }
6145
6146    Ok(out.trim_end().to_string())
6147}
6148
6149// ── dns_cache ─────────────────────────────────────────────────────────────────
6150
6151fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
6152    let mut out = String::from("Host inspection: dns_cache\n\n");
6153    let n = max_entries.clamp(10, 100);
6154
6155    #[cfg(target_os = "windows")]
6156    {
6157        let output = Command::new("powershell")
6158            .args([
6159                "-NoProfile",
6160                "-Command",
6161                "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
6162            ])
6163            .output()
6164            .map_err(|e| format!("dns_cache: {e}"))?;
6165
6166        let raw = String::from_utf8_lossy(&output.stdout);
6167        let lines: Vec<&str> = raw.lines().skip(1).collect();
6168        let total = lines.len();
6169
6170        if total == 0 {
6171            out.push_str("DNS cache is empty or could not be read.\n");
6172        } else {
6173            out.push_str(&format!(
6174                "DNS cache entries (showing up to {n} of {total}):\n\n"
6175            ));
6176            let mut shown = 0usize;
6177            for line in lines.iter().take(n) {
6178                let cols: Vec<&str> = line.splitn(4, ',').collect();
6179                if cols.len() >= 3 {
6180                    let entry = cols[0].trim_matches('"');
6181                    let rtype = cols[1].trim_matches('"');
6182                    let data = cols[2].trim_matches('"');
6183                    let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
6184                    out.push_str(&format!("  {entry:<45} {rtype:<6} {data}  (TTL {ttl}s)\n"));
6185                    shown += 1;
6186                }
6187            }
6188            if total > shown {
6189                out.push_str(&format!("\n  ... and {} more entries\n", total - shown));
6190            }
6191        }
6192    }
6193
6194    #[cfg(not(target_os = "windows"))]
6195    {
6196        if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
6197            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6198            if !text.is_empty() {
6199                out.push_str("systemd-resolved statistics:\n");
6200                for line in text.lines().take(n) {
6201                    out.push_str(&format!("  {line}\n"));
6202                }
6203                out.push('\n');
6204            }
6205        }
6206        if let Ok(o) = Command::new("dscacheutil")
6207            .args(["-cachedump", "-entries", "Host"])
6208            .output()
6209        {
6210            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6211            if !text.is_empty() {
6212                out.push_str("DNS cache (macOS dscacheutil):\n");
6213                for line in text.lines().take(n) {
6214                    out.push_str(&format!("  {line}\n"));
6215                }
6216            } else {
6217                out.push_str("DNS cache is empty or not accessible on this platform.\n");
6218            }
6219        } else {
6220            out.push_str(
6221                "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
6222            );
6223        }
6224    }
6225
6226    Ok(out.trim_end().to_string())
6227}
6228
6229// ── arp ───────────────────────────────────────────────────────────────────────
6230
6231fn inspect_arp() -> Result<String, String> {
6232    let mut out = String::from("Host inspection: arp\n\n");
6233
6234    #[cfg(target_os = "windows")]
6235    {
6236        let output = Command::new("arp")
6237            .args(["-a"])
6238            .output()
6239            .map_err(|e| format!("arp: {e}"))?;
6240        let raw = String::from_utf8_lossy(&output.stdout);
6241        let mut count = 0usize;
6242        for line in raw.lines() {
6243            let t = line.trim();
6244            if t.is_empty() {
6245                continue;
6246            }
6247            out.push_str(&format!("  {t}\n"));
6248            if t.contains("dynamic") || t.contains("static") {
6249                count += 1;
6250            }
6251        }
6252        out.push_str(&format!("\nTotal entries: {count}\n"));
6253    }
6254
6255    #[cfg(not(target_os = "windows"))]
6256    {
6257        if let Ok(o) = Command::new("arp").args(["-n"]).output() {
6258            let raw = String::from_utf8_lossy(&o.stdout);
6259            let mut count = 0usize;
6260            for line in raw.lines() {
6261                let t = line.trim();
6262                if !t.is_empty() {
6263                    out.push_str(&format!("  {t}\n"));
6264                    count += 1;
6265                }
6266            }
6267            out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
6268        } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
6269            let raw = String::from_utf8_lossy(&o.stdout);
6270            let mut count = 0usize;
6271            for line in raw.lines() {
6272                let t = line.trim();
6273                if !t.is_empty() {
6274                    out.push_str(&format!("  {t}\n"));
6275                    count += 1;
6276                }
6277            }
6278            out.push_str(&format!("\nTotal entries: {count}\n"));
6279        } else {
6280            out.push_str("arp and ip neigh not available.\n");
6281        }
6282    }
6283
6284    Ok(out.trim_end().to_string())
6285}
6286
6287// ── route_table ───────────────────────────────────────────────────────────────
6288
6289fn inspect_route_table(max_entries: usize) -> Result<String, String> {
6290    let mut out = String::from("Host inspection: route_table\n\n");
6291    let n = max_entries.clamp(10, 50);
6292
6293    #[cfg(target_os = "windows")]
6294    {
6295        let script = r#"
6296try {
6297    $routes = Get-NetRoute -ErrorAction Stop |
6298        Where-Object { $_.RouteMetric -lt 9000 } |
6299        Sort-Object RouteMetric |
6300        Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
6301    "TOTAL:" + $routes.Count
6302    $routes | ForEach-Object {
6303        $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
6304    }
6305} catch { "ERROR:" + $_.Exception.Message }
6306"#;
6307        let output = Command::new("powershell")
6308            .args(["-NoProfile", "-Command", script])
6309            .output()
6310            .map_err(|e| format!("route_table: {e}"))?;
6311        let raw = String::from_utf8_lossy(&output.stdout);
6312        let text = raw.trim();
6313
6314        if text.starts_with("ERROR:") {
6315            out.push_str(&format!(
6316                "Unable to read route table: {}\n",
6317                text.trim_start_matches("ERROR:").trim()
6318            ));
6319        } else {
6320            let mut shown = 0usize;
6321            for line in text.lines() {
6322                if let Some(rest) = line.strip_prefix("TOTAL:") {
6323                    let total: usize = rest.trim().parse().unwrap_or(0);
6324                    out.push_str(&format!(
6325                        "Routing table (showing up to {n} of {total} routes):\n\n"
6326                    ));
6327                    out.push_str(&format!(
6328                        "  {:<22} {:<18} {:>8}  Interface\n",
6329                        "Destination", "Next Hop", "Metric"
6330                    ));
6331                    out.push_str(&format!("  {}\n", "-".repeat(70)));
6332                } else if shown < n {
6333                    let parts: Vec<&str> = line.splitn(4, '|').collect();
6334                    if parts.len() == 4 {
6335                        let dest = parts[0];
6336                        let hop =
6337                            if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
6338                                "on-link"
6339                            } else {
6340                                parts[1]
6341                            };
6342                        let metric = parts[2];
6343                        let iface = parts[3];
6344                        out.push_str(&format!("  {dest:<22} {hop:<18} {metric:>8}  {iface}\n"));
6345                        shown += 1;
6346                    }
6347                }
6348            }
6349        }
6350    }
6351
6352    #[cfg(not(target_os = "windows"))]
6353    {
6354        if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
6355            let raw = String::from_utf8_lossy(&o.stdout);
6356            let lines: Vec<&str> = raw.lines().collect();
6357            let total = lines.len();
6358            out.push_str(&format!(
6359                "Routing table (showing up to {n} of {total} routes):\n\n"
6360            ));
6361            for line in lines.iter().take(n) {
6362                out.push_str(&format!("  {line}\n"));
6363            }
6364            if total > n {
6365                out.push_str(&format!("\n  ... and {} more routes\n", total - n));
6366            }
6367        } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
6368            let raw = String::from_utf8_lossy(&o.stdout);
6369            for line in raw.lines().take(n) {
6370                out.push_str(&format!("  {line}\n"));
6371            }
6372        } else {
6373            out.push_str("ip route and netstat not available.\n");
6374        }
6375    }
6376
6377    Ok(out.trim_end().to_string())
6378}
6379
6380// ── env ───────────────────────────────────────────────────────────────────────
6381
6382fn inspect_env(max_entries: usize) -> Result<String, String> {
6383    let mut out = String::from("Host inspection: env\n\n");
6384    let n = max_entries.clamp(10, 50);
6385
6386    fn looks_like_secret(name: &str) -> bool {
6387        let n = name.to_uppercase();
6388        n.contains("KEY")
6389            || n.contains("SECRET")
6390            || n.contains("TOKEN")
6391            || n.contains("PASSWORD")
6392            || n.contains("PASSWD")
6393            || n.contains("CREDENTIAL")
6394            || n.contains("AUTH")
6395            || n.contains("CERT")
6396            || n.contains("PRIVATE")
6397    }
6398
6399    let known_dev_vars: &[&str] = &[
6400        "CARGO_HOME",
6401        "RUSTUP_HOME",
6402        "GOPATH",
6403        "GOROOT",
6404        "GOBIN",
6405        "JAVA_HOME",
6406        "ANDROID_HOME",
6407        "ANDROID_SDK_ROOT",
6408        "PYTHONPATH",
6409        "PYTHONHOME",
6410        "VIRTUAL_ENV",
6411        "CONDA_DEFAULT_ENV",
6412        "CONDA_PREFIX",
6413        "NODE_PATH",
6414        "NVM_DIR",
6415        "NVM_BIN",
6416        "PNPM_HOME",
6417        "DENO_INSTALL",
6418        "DENO_DIR",
6419        "DOTNET_ROOT",
6420        "NUGET_PACKAGES",
6421        "CMAKE_HOME",
6422        "VCPKG_ROOT",
6423        "AWS_PROFILE",
6424        "AWS_REGION",
6425        "AWS_DEFAULT_REGION",
6426        "GCP_PROJECT",
6427        "GOOGLE_CLOUD_PROJECT",
6428        "GOOGLE_APPLICATION_CREDENTIALS",
6429        "AZURE_SUBSCRIPTION_ID",
6430        "DATABASE_URL",
6431        "REDIS_URL",
6432        "MONGO_URI",
6433        "EDITOR",
6434        "VISUAL",
6435        "SHELL",
6436        "TERM",
6437        "XDG_CONFIG_HOME",
6438        "XDG_DATA_HOME",
6439        "XDG_CACHE_HOME",
6440        "HOME",
6441        "USERPROFILE",
6442        "APPDATA",
6443        "LOCALAPPDATA",
6444        "TEMP",
6445        "TMP",
6446        "COMPUTERNAME",
6447        "USERNAME",
6448        "USERDOMAIN",
6449        "PROCESSOR_ARCHITECTURE",
6450        "NUMBER_OF_PROCESSORS",
6451        "OS",
6452        "HOMEDRIVE",
6453        "HOMEPATH",
6454        "HTTP_PROXY",
6455        "HTTPS_PROXY",
6456        "NO_PROXY",
6457        "ALL_PROXY",
6458        "http_proxy",
6459        "https_proxy",
6460        "no_proxy",
6461        "DOCKER_HOST",
6462        "DOCKER_BUILDKIT",
6463        "COMPOSE_PROJECT_NAME",
6464        "KUBECONFIG",
6465        "KUBE_CONTEXT",
6466        "CI",
6467        "GITHUB_ACTIONS",
6468        "GITLAB_CI",
6469        "LMSTUDIO_HOME",
6470        "HEMATITE_URL",
6471    ];
6472
6473    let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
6474    all_vars.sort_by(|a, b| a.0.cmp(&b.0));
6475    let total = all_vars.len();
6476
6477    let mut dev_found: Vec<String> = Vec::new();
6478    let mut secret_found: Vec<String> = Vec::new();
6479
6480    for (k, v) in &all_vars {
6481        if k == "PATH" {
6482            continue;
6483        }
6484        if looks_like_secret(k) {
6485            secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
6486        } else {
6487            let k_upper = k.to_uppercase();
6488            let is_known = known_dev_vars
6489                .iter()
6490                .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
6491            if is_known {
6492                let display = if v.len() > 120 {
6493                    format!("{k} = {}…", &v[..117])
6494                } else {
6495                    format!("{k} = {v}")
6496                };
6497                dev_found.push(display);
6498            }
6499        }
6500    }
6501
6502    out.push_str(&format!("Total environment variables: {total}\n\n"));
6503
6504    if let Ok(p) = std::env::var("PATH") {
6505        let sep = if cfg!(target_os = "windows") {
6506            ';'
6507        } else {
6508            ':'
6509        };
6510        let count = p.split(sep).count();
6511        out.push_str(&format!(
6512            "PATH: {count} entries (use topic=path for full audit)\n\n"
6513        ));
6514    }
6515
6516    if !secret_found.is_empty() {
6517        out.push_str(&format!(
6518            "=== Secret/credential variables ({} detected, values hidden) ===\n",
6519            secret_found.len()
6520        ));
6521        for s in secret_found.iter().take(n) {
6522            out.push_str(&format!("  {s}\n"));
6523        }
6524        out.push('\n');
6525    }
6526
6527    if !dev_found.is_empty() {
6528        out.push_str(&format!(
6529            "=== Developer & tool variables ({}) ===\n",
6530            dev_found.len()
6531        ));
6532        for d in dev_found.iter().take(n) {
6533            out.push_str(&format!("  {d}\n"));
6534        }
6535        out.push('\n');
6536    }
6537
6538    let other_count = all_vars
6539        .iter()
6540        .filter(|(k, _)| {
6541            k != "PATH"
6542                && !looks_like_secret(k)
6543                && !known_dev_vars
6544                    .iter()
6545                    .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
6546        })
6547        .count();
6548    if other_count > 0 {
6549        out.push_str(&format!(
6550            "Other variables: {other_count} (use 'env' in shell to see all)\n"
6551        ));
6552    }
6553
6554    Ok(out.trim_end().to_string())
6555}
6556
6557// ── hosts_file ────────────────────────────────────────────────────────────────
6558
6559fn inspect_hosts_file() -> Result<String, String> {
6560    let mut out = String::from("Host inspection: hosts_file\n\n");
6561
6562    let hosts_path = if cfg!(target_os = "windows") {
6563        std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
6564    } else {
6565        std::path::PathBuf::from("/etc/hosts")
6566    };
6567
6568    out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
6569
6570    match fs::read_to_string(&hosts_path) {
6571        Ok(content) => {
6572            let mut active_entries: Vec<String> = Vec::new();
6573            let mut comment_lines = 0usize;
6574            let mut blank_lines = 0usize;
6575
6576            for line in content.lines() {
6577                let t = line.trim();
6578                if t.is_empty() {
6579                    blank_lines += 1;
6580                } else if t.starts_with('#') {
6581                    comment_lines += 1;
6582                } else {
6583                    active_entries.push(line.to_string());
6584                }
6585            }
6586
6587            out.push_str(&format!(
6588                "Active entries: {}  |  Comment lines: {}  |  Blank lines: {}\n\n",
6589                active_entries.len(),
6590                comment_lines,
6591                blank_lines
6592            ));
6593
6594            if active_entries.is_empty() {
6595                out.push_str(
6596                    "No active host entries (file contains only comments/blanks — standard default state).\n",
6597                );
6598            } else {
6599                out.push_str("=== Active entries ===\n");
6600                for entry in &active_entries {
6601                    out.push_str(&format!("  {entry}\n"));
6602                }
6603                out.push('\n');
6604
6605                let custom: Vec<&String> = active_entries
6606                    .iter()
6607                    .filter(|e| {
6608                        let t = e.trim_start();
6609                        !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
6610                    })
6611                    .collect();
6612                if !custom.is_empty() {
6613                    out.push_str(&format!(
6614                        "[!] Custom (non-loopback) entries: {}\n",
6615                        custom.len()
6616                    ));
6617                    for e in &custom {
6618                        out.push_str(&format!("  {e}\n"));
6619                    }
6620                } else {
6621                    out.push_str("All active entries are standard loopback or block entries.\n");
6622                }
6623            }
6624
6625            out.push_str("\n=== Full file ===\n");
6626            for line in content.lines() {
6627                out.push_str(&format!("  {line}\n"));
6628            }
6629        }
6630        Err(e) => {
6631            out.push_str(&format!("Could not read hosts file: {e}\n"));
6632            if cfg!(target_os = "windows") {
6633                out.push_str(
6634                    "On Windows, run Hematite as Administrator if permission is denied.\n",
6635                );
6636            }
6637        }
6638    }
6639
6640    Ok(out.trim_end().to_string())
6641}
6642
6643// ── docker ────────────────────────────────────────────────────────────────────
6644
6645fn inspect_docker(max_entries: usize) -> Result<String, String> {
6646    let mut out = String::from("Host inspection: docker\n\n");
6647    let n = max_entries.clamp(5, 25);
6648
6649    let version_output = Command::new("docker")
6650        .args(["version", "--format", "{{.Server.Version}}"])
6651        .output();
6652
6653    match version_output {
6654        Err(_) => {
6655            out.push_str("Docker: not found on PATH.\n");
6656            out.push_str(
6657                "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
6658            );
6659            return Ok(out.trim_end().to_string());
6660        }
6661        Ok(o) if !o.status.success() => {
6662            let stderr = String::from_utf8_lossy(&o.stderr);
6663            if stderr.contains("cannot connect")
6664                || stderr.contains("Is the docker daemon running")
6665                || stderr.contains("pipe")
6666                || stderr.contains("socket")
6667            {
6668                out.push_str("Docker: installed but daemon is NOT running.\n");
6669                out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
6670            } else {
6671                out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
6672            }
6673            return Ok(out.trim_end().to_string());
6674        }
6675        Ok(o) => {
6676            let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
6677            out.push_str(&format!("Docker Engine: {version}\n"));
6678        }
6679    }
6680
6681    if let Ok(o) = Command::new("docker")
6682        .args([
6683            "info",
6684            "--format",
6685            "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
6686        ])
6687        .output()
6688    {
6689        let info = String::from_utf8_lossy(&o.stdout);
6690        for line in info.lines() {
6691            let t = line.trim();
6692            if !t.is_empty() {
6693                out.push_str(&format!("  {t}\n"));
6694            }
6695        }
6696        out.push('\n');
6697    }
6698
6699    if let Ok(o) = Command::new("docker")
6700        .args([
6701            "ps",
6702            "--format",
6703            "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
6704        ])
6705        .output()
6706    {
6707        let raw = String::from_utf8_lossy(&o.stdout);
6708        let lines: Vec<&str> = raw.lines().collect();
6709        if lines.len() <= 1 {
6710            out.push_str("Running containers: none\n\n");
6711        } else {
6712            out.push_str(&format!(
6713                "=== Running containers ({}) ===\n",
6714                lines.len().saturating_sub(1)
6715            ));
6716            for line in lines.iter().take(n + 1) {
6717                out.push_str(&format!("  {line}\n"));
6718            }
6719            if lines.len() > n + 1 {
6720                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
6721            }
6722            out.push('\n');
6723        }
6724    }
6725
6726    if let Ok(o) = Command::new("docker")
6727        .args([
6728            "images",
6729            "--format",
6730            "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
6731        ])
6732        .output()
6733    {
6734        let raw = String::from_utf8_lossy(&o.stdout);
6735        let lines: Vec<&str> = raw.lines().collect();
6736        if lines.len() > 1 {
6737            out.push_str(&format!(
6738                "=== Local images ({}) ===\n",
6739                lines.len().saturating_sub(1)
6740            ));
6741            for line in lines.iter().take(n + 1) {
6742                out.push_str(&format!("  {line}\n"));
6743            }
6744            if lines.len() > n + 1 {
6745                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
6746            }
6747            out.push('\n');
6748        }
6749    }
6750
6751    if let Ok(o) = Command::new("docker")
6752        .args([
6753            "compose",
6754            "ls",
6755            "--format",
6756            "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
6757        ])
6758        .output()
6759    {
6760        let raw = String::from_utf8_lossy(&o.stdout);
6761        let lines: Vec<&str> = raw.lines().collect();
6762        if lines.len() > 1 {
6763            out.push_str(&format!(
6764                "=== Compose projects ({}) ===\n",
6765                lines.len().saturating_sub(1)
6766            ));
6767            for line in lines.iter().take(n + 1) {
6768                out.push_str(&format!("  {line}\n"));
6769            }
6770            out.push('\n');
6771        }
6772    }
6773
6774    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
6775        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
6776        if !ctx.is_empty() {
6777            out.push_str(&format!("Active context: {ctx}\n"));
6778        }
6779    }
6780
6781    Ok(out.trim_end().to_string())
6782}
6783
6784// ── wsl ───────────────────────────────────────────────────────────────────────
6785
6786fn inspect_wsl() -> Result<String, String> {
6787    let mut out = String::from("Host inspection: wsl\n\n");
6788
6789    #[cfg(target_os = "windows")]
6790    {
6791        if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
6792            let raw = String::from_utf8_lossy(&o.stdout);
6793            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6794            for line in cleaned.lines().take(4) {
6795                let t = line.trim();
6796                if !t.is_empty() {
6797                    out.push_str(&format!("  {t}\n"));
6798                }
6799            }
6800            out.push('\n');
6801        }
6802
6803        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
6804        match list_output {
6805            Err(e) => {
6806                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
6807                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
6808            }
6809            Ok(o) if !o.status.success() => {
6810                let stderr = String::from_utf8_lossy(&o.stderr);
6811                let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
6812                out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
6813                out.push_str("Run: wsl --install\n");
6814            }
6815            Ok(o) => {
6816                let raw = String::from_utf8_lossy(&o.stdout);
6817                let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6818                let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
6819                let distro_lines: Vec<&str> = lines
6820                    .iter()
6821                    .filter(|l| {
6822                        let t = l.trim();
6823                        !t.is_empty()
6824                            && !t.to_uppercase().starts_with("NAME")
6825                            && !t.starts_with("---")
6826                    })
6827                    .copied()
6828                    .collect();
6829
6830                if distro_lines.is_empty() {
6831                    out.push_str("WSL: installed but no distributions found.\n");
6832                    out.push_str("Install a distro: wsl --install -d Ubuntu\n");
6833                } else {
6834                    out.push_str("=== WSL Distributions ===\n");
6835                    for line in &lines {
6836                        out.push_str(&format!("  {}\n", line.trim()));
6837                    }
6838                    out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
6839                }
6840            }
6841        }
6842
6843        if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
6844            let raw = String::from_utf8_lossy(&o.stdout);
6845            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6846            let status_lines: Vec<&str> = cleaned
6847                .lines()
6848                .filter(|l| !l.trim().is_empty())
6849                .take(8)
6850                .collect();
6851            if !status_lines.is_empty() {
6852                out.push_str("\n=== WSL status ===\n");
6853                for line in status_lines {
6854                    out.push_str(&format!("  {}\n", line.trim()));
6855                }
6856            }
6857        }
6858    }
6859
6860    #[cfg(not(target_os = "windows"))]
6861    {
6862        out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
6863        out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
6864    }
6865
6866    Ok(out.trim_end().to_string())
6867}
6868
6869// ── ssh ───────────────────────────────────────────────────────────────────────
6870
6871fn dirs_home() -> Option<PathBuf> {
6872    std::env::var("HOME")
6873        .ok()
6874        .map(PathBuf::from)
6875        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
6876}
6877
6878fn inspect_ssh() -> Result<String, String> {
6879    let mut out = String::from("Host inspection: ssh\n\n");
6880
6881    if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
6882        let ver = if o.stdout.is_empty() {
6883            String::from_utf8_lossy(&o.stderr).trim().to_string()
6884        } else {
6885            String::from_utf8_lossy(&o.stdout).trim().to_string()
6886        };
6887        if !ver.is_empty() {
6888            out.push_str(&format!("SSH client: {ver}\n"));
6889        }
6890    } else {
6891        out.push_str("SSH client: not found on PATH.\n");
6892    }
6893
6894    #[cfg(target_os = "windows")]
6895    {
6896        let script = r#"
6897$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
6898if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
6899else { "SSHD:not_installed" }
6900"#;
6901        if let Ok(o) = Command::new("powershell")
6902            .args(["-NoProfile", "-Command", script])
6903            .output()
6904        {
6905            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6906            if text.contains("not_installed") {
6907                out.push_str("SSH server (sshd): not installed\n");
6908            } else {
6909                out.push_str(&format!(
6910                    "SSH server (sshd): {}\n",
6911                    text.trim_start_matches("SSHD:")
6912                ));
6913            }
6914        }
6915    }
6916
6917    #[cfg(not(target_os = "windows"))]
6918    {
6919        if let Ok(o) = Command::new("systemctl")
6920            .args(["is-active", "sshd"])
6921            .output()
6922        {
6923            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
6924            out.push_str(&format!("SSH server (sshd): {status}\n"));
6925        } else if let Ok(o) = Command::new("systemctl")
6926            .args(["is-active", "ssh"])
6927            .output()
6928        {
6929            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
6930            out.push_str(&format!("SSH server (ssh): {status}\n"));
6931        }
6932    }
6933
6934    out.push('\n');
6935
6936    if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
6937        if ssh_dir.exists() {
6938            out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
6939
6940            let kh = ssh_dir.join("known_hosts");
6941            if kh.exists() {
6942                let count = fs::read_to_string(&kh)
6943                    .map(|c| {
6944                        c.lines()
6945                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
6946                            .count()
6947                    })
6948                    .unwrap_or(0);
6949                out.push_str(&format!("  known_hosts: {count} entries\n"));
6950            } else {
6951                out.push_str("  known_hosts: not present\n");
6952            }
6953
6954            let ak = ssh_dir.join("authorized_keys");
6955            if ak.exists() {
6956                let count = fs::read_to_string(&ak)
6957                    .map(|c| {
6958                        c.lines()
6959                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
6960                            .count()
6961                    })
6962                    .unwrap_or(0);
6963                out.push_str(&format!("  authorized_keys: {count} public keys\n"));
6964            } else {
6965                out.push_str("  authorized_keys: not present\n");
6966            }
6967
6968            let key_names = [
6969                "id_rsa",
6970                "id_ed25519",
6971                "id_ecdsa",
6972                "id_dsa",
6973                "id_ecdsa_sk",
6974                "id_ed25519_sk",
6975            ];
6976            let found_keys: Vec<&str> = key_names
6977                .iter()
6978                .filter(|k| ssh_dir.join(k).exists())
6979                .copied()
6980                .collect();
6981            if !found_keys.is_empty() {
6982                out.push_str(&format!("  Private keys: {}\n", found_keys.join(", ")));
6983            } else {
6984                out.push_str("  Private keys: none found\n");
6985            }
6986
6987            let config_path = ssh_dir.join("config");
6988            if config_path.exists() {
6989                out.push_str("\n=== SSH config hosts ===\n");
6990                match fs::read_to_string(&config_path) {
6991                    Ok(content) => {
6992                        let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
6993                        let mut current: Option<(String, Vec<String>)> = None;
6994                        for line in content.lines() {
6995                            let t = line.trim();
6996                            if t.is_empty() || t.starts_with('#') {
6997                                continue;
6998                            }
6999                            if let Some(host) = t.strip_prefix("Host ") {
7000                                if let Some(prev) = current.take() {
7001                                    hosts.push(prev);
7002                                }
7003                                current = Some((host.trim().to_string(), Vec::new()));
7004                            } else if let Some((_, ref mut details)) = current {
7005                                let tu = t.to_uppercase();
7006                                if tu.starts_with("HOSTNAME ")
7007                                    || tu.starts_with("USER ")
7008                                    || tu.starts_with("PORT ")
7009                                    || tu.starts_with("IDENTITYFILE ")
7010                                {
7011                                    details.push(t.to_string());
7012                                }
7013                            }
7014                        }
7015                        if let Some(prev) = current {
7016                            hosts.push(prev);
7017                        }
7018
7019                        if hosts.is_empty() {
7020                            out.push_str("  No Host entries found.\n");
7021                        } else {
7022                            for (h, details) in &hosts {
7023                                if details.is_empty() {
7024                                    out.push_str(&format!("  Host {h}\n"));
7025                                } else {
7026                                    out.push_str(&format!(
7027                                        "  Host {h}  [{}]\n",
7028                                        details.join(", ")
7029                                    ));
7030                                }
7031                            }
7032                            out.push_str(&format!("\n  Total configured hosts: {}\n", hosts.len()));
7033                        }
7034                    }
7035                    Err(e) => out.push_str(&format!("  Could not read config: {e}\n")),
7036                }
7037            } else {
7038                out.push_str("  SSH config: not present\n");
7039            }
7040        } else {
7041            out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
7042        }
7043    }
7044
7045    Ok(out.trim_end().to_string())
7046}
7047
7048// ── installed_software ────────────────────────────────────────────────────────
7049
7050fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
7051    let mut out = String::from("Host inspection: installed_software\n\n");
7052    let n = max_entries.clamp(10, 50);
7053
7054    #[cfg(target_os = "windows")]
7055    {
7056        let winget_out = Command::new("winget")
7057            .args(["list", "--accept-source-agreements"])
7058            .output();
7059
7060        if let Ok(o) = winget_out {
7061            if o.status.success() {
7062                let raw = String::from_utf8_lossy(&o.stdout);
7063                let mut header_done = false;
7064                let mut packages: Vec<&str> = Vec::new();
7065                for line in raw.lines() {
7066                    let t = line.trim();
7067                    if t.starts_with("---") {
7068                        header_done = true;
7069                        continue;
7070                    }
7071                    if header_done && !t.is_empty() {
7072                        packages.push(line);
7073                    }
7074                }
7075                let total = packages.len();
7076                out.push_str(&format!(
7077                    "=== Installed software via winget ({total} packages) ===\n\n"
7078                ));
7079                for line in packages.iter().take(n) {
7080                    out.push_str(&format!("  {line}\n"));
7081                }
7082                if total > n {
7083                    out.push_str(&format!("\n  ... and {} more packages\n", total - n));
7084                }
7085                out.push_str("\nFor full list: winget list\n");
7086                return Ok(out.trim_end().to_string());
7087            }
7088        }
7089
7090        // Fallback: registry scan
7091        let script = format!(
7092            r#"
7093$apps = @()
7094$reg_paths = @(
7095    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
7096    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
7097    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
7098)
7099foreach ($p in $reg_paths) {{
7100    try {{
7101        $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
7102            Where-Object {{ $_.DisplayName }} |
7103            Select-Object DisplayName, DisplayVersion, Publisher
7104    }} catch {{}}
7105}}
7106$sorted = $apps | Sort-Object DisplayName -Unique
7107"TOTAL:" + $sorted.Count
7108$sorted | Select-Object -First {n} | ForEach-Object {{
7109    $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
7110}}
7111"#
7112        );
7113        if let Ok(o) = Command::new("powershell")
7114            .args(["-NoProfile", "-Command", &script])
7115            .output()
7116        {
7117            let raw = String::from_utf8_lossy(&o.stdout);
7118            out.push_str("=== Installed software (registry scan) ===\n");
7119            out.push_str(&format!("  {:<50} {:<18} Publisher\n", "Name", "Version"));
7120            out.push_str(&format!("  {}\n", "-".repeat(90)));
7121            for line in raw.lines() {
7122                if let Some(rest) = line.strip_prefix("TOTAL:") {
7123                    let total: usize = rest.trim().parse().unwrap_or(0);
7124                    out.push_str(&format!("  (Total: {total}, showing first {n})\n\n"));
7125                } else if !line.trim().is_empty() {
7126                    let parts: Vec<&str> = line.splitn(3, '|').collect();
7127                    let name = parts.first().map(|s| s.trim()).unwrap_or("");
7128                    let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
7129                    let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
7130                    out.push_str(&format!("  {:<50} {:<18} {pub_}\n", name, ver));
7131                }
7132            }
7133        } else {
7134            out.push_str(
7135                "Could not query installed software (winget and registry scan both failed).\n",
7136            );
7137        }
7138    }
7139
7140    #[cfg(target_os = "linux")]
7141    {
7142        let mut found = false;
7143        if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
7144            if o.status.success() {
7145                let raw = String::from_utf8_lossy(&o.stdout);
7146                let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
7147                let total = installed.len();
7148                out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
7149                for line in installed.iter().take(n) {
7150                    out.push_str(&format!("  {}\n", line.trim()));
7151                }
7152                if total > n {
7153                    out.push_str(&format!("  ... and {} more\n", total - n));
7154                }
7155                out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
7156                found = true;
7157            }
7158        }
7159        if !found {
7160            if let Ok(o) = Command::new("rpm")
7161                .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
7162                .output()
7163            {
7164                if o.status.success() {
7165                    let raw = String::from_utf8_lossy(&o.stdout);
7166                    let lines: Vec<&str> = raw.lines().collect();
7167                    let total = lines.len();
7168                    out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
7169                    for line in lines.iter().take(n) {
7170                        out.push_str(&format!("  {line}\n"));
7171                    }
7172                    if total > n {
7173                        out.push_str(&format!("  ... and {} more\n", total - n));
7174                    }
7175                    found = true;
7176                }
7177            }
7178        }
7179        if !found {
7180            if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
7181                if o.status.success() {
7182                    let raw = String::from_utf8_lossy(&o.stdout);
7183                    let lines: Vec<&str> = raw.lines().collect();
7184                    let total = lines.len();
7185                    out.push_str(&format!(
7186                        "=== Installed packages via pacman ({total}) ===\n"
7187                    ));
7188                    for line in lines.iter().take(n) {
7189                        out.push_str(&format!("  {line}\n"));
7190                    }
7191                    if total > n {
7192                        out.push_str(&format!("  ... and {} more\n", total - n));
7193                    }
7194                    found = true;
7195                }
7196            }
7197        }
7198        if !found {
7199            out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
7200        }
7201    }
7202
7203    #[cfg(target_os = "macos")]
7204    {
7205        if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
7206            if o.status.success() {
7207                let raw = String::from_utf8_lossy(&o.stdout);
7208                let lines: Vec<&str> = raw.lines().collect();
7209                let total = lines.len();
7210                out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
7211                for line in lines.iter().take(n) {
7212                    out.push_str(&format!("  {line}\n"));
7213                }
7214                if total > n {
7215                    out.push_str(&format!("  ... and {} more\n", total - n));
7216                }
7217                out.push_str("\nFor full list: brew list --versions\n");
7218            }
7219        } else {
7220            out.push_str("Homebrew not found.\n");
7221        }
7222        if let Ok(o) = Command::new("mas").args(["list"]).output() {
7223            if o.status.success() {
7224                let raw = String::from_utf8_lossy(&o.stdout);
7225                let lines: Vec<&str> = raw.lines().collect();
7226                out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
7227                for line in lines.iter().take(n) {
7228                    out.push_str(&format!("  {line}\n"));
7229                }
7230            }
7231        }
7232    }
7233
7234    Ok(out.trim_end().to_string())
7235}
7236
7237// ── git_config ────────────────────────────────────────────────────────────────
7238
7239fn inspect_git_config() -> Result<String, String> {
7240    let mut out = String::from("Host inspection: git_config\n\n");
7241
7242    if let Ok(o) = Command::new("git").args(["--version"]).output() {
7243        let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
7244        out.push_str(&format!("Git: {ver}\n\n"));
7245    } else {
7246        out.push_str("Git: not found on PATH.\n");
7247        return Ok(out.trim_end().to_string());
7248    }
7249
7250    if let Ok(o) = Command::new("git")
7251        .args(["config", "--global", "--list"])
7252        .output()
7253    {
7254        if o.status.success() {
7255            let raw = String::from_utf8_lossy(&o.stdout);
7256            let mut pairs: Vec<(String, String)> = raw
7257                .lines()
7258                .filter_map(|l| {
7259                    let mut parts = l.splitn(2, '=');
7260                    let k = parts.next()?.trim().to_string();
7261                    let v = parts.next().unwrap_or("").trim().to_string();
7262                    Some((k, v))
7263                })
7264                .collect();
7265            pairs.sort_by(|a, b| a.0.cmp(&b.0));
7266
7267            out.push_str("=== Global git config ===\n");
7268
7269            let sections: &[(&str, &[&str])] = &[
7270                ("Identity", &["user.name", "user.email", "user.signingkey"]),
7271                (
7272                    "Core",
7273                    &[
7274                        "core.editor",
7275                        "core.autocrlf",
7276                        "core.eol",
7277                        "core.ignorecase",
7278                        "core.filemode",
7279                    ],
7280                ),
7281                (
7282                    "Commit/Signing",
7283                    &[
7284                        "commit.gpgsign",
7285                        "tag.gpgsign",
7286                        "gpg.format",
7287                        "gpg.ssh.allowedsignersfile",
7288                    ],
7289                ),
7290                (
7291                    "Push/Pull",
7292                    &[
7293                        "push.default",
7294                        "push.autosetupremote",
7295                        "pull.rebase",
7296                        "pull.ff",
7297                    ],
7298                ),
7299                ("Credential", &["credential.helper"]),
7300                ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
7301            ];
7302
7303            let mut shown_keys: HashSet<String> = HashSet::new();
7304            for (section, keys) in sections {
7305                let mut section_lines: Vec<String> = Vec::new();
7306                for key in *keys {
7307                    if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
7308                        section_lines.push(format!("  {k} = {v}"));
7309                        shown_keys.insert(k.clone());
7310                    }
7311                }
7312                if !section_lines.is_empty() {
7313                    out.push_str(&format!("\n[{section}]\n"));
7314                    for line in section_lines {
7315                        out.push_str(&format!("{line}\n"));
7316                    }
7317                }
7318            }
7319
7320            let other: Vec<&(String, String)> = pairs
7321                .iter()
7322                .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
7323                .collect();
7324            if !other.is_empty() {
7325                out.push_str("\n[Other]\n");
7326                for (k, v) in other.iter().take(20) {
7327                    out.push_str(&format!("  {k} = {v}\n"));
7328                }
7329                if other.len() > 20 {
7330                    out.push_str(&format!("  ... and {} more\n", other.len() - 20));
7331                }
7332            }
7333
7334            out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
7335        } else {
7336            out.push_str("No global git config found.\n");
7337            out.push_str("Set up with:\n");
7338            out.push_str("  git config --global user.name \"Your Name\"\n");
7339            out.push_str("  git config --global user.email \"you@example.com\"\n");
7340        }
7341    }
7342
7343    if let Ok(o) = Command::new("git")
7344        .args(["config", "--local", "--list"])
7345        .output()
7346    {
7347        if o.status.success() {
7348            let raw = String::from_utf8_lossy(&o.stdout);
7349            let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
7350            if !lines.is_empty() {
7351                out.push_str(&format!(
7352                    "\n=== Local repo config ({} keys) ===\n",
7353                    lines.len()
7354                ));
7355                for line in lines.iter().take(15) {
7356                    out.push_str(&format!("  {line}\n"));
7357                }
7358                if lines.len() > 15 {
7359                    out.push_str(&format!("  ... and {} more\n", lines.len() - 15));
7360                }
7361            }
7362        }
7363    }
7364
7365    if let Ok(o) = Command::new("git")
7366        .args(["config", "--global", "--get-regexp", r"alias\."])
7367        .output()
7368    {
7369        if o.status.success() {
7370            let raw = String::from_utf8_lossy(&o.stdout);
7371            let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
7372            if !aliases.is_empty() {
7373                out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
7374                for a in aliases.iter().take(20) {
7375                    out.push_str(&format!("  {a}\n"));
7376                }
7377                if aliases.len() > 20 {
7378                    out.push_str(&format!("  ... and {} more\n", aliases.len() - 20));
7379                }
7380            }
7381        }
7382    }
7383
7384    Ok(out.trim_end().to_string())
7385}
7386
7387// ── databases ─────────────────────────────────────────────────────────────────
7388
7389fn inspect_databases() -> Result<String, String> {
7390    let mut out = String::from("Host inspection: databases\n\n");
7391    out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
7392
7393    struct DbEngine {
7394        name: &'static str,
7395        service_names: &'static [&'static str],
7396        default_port: u16,
7397        cli_name: &'static str,
7398        cli_version_args: &'static [&'static str],
7399    }
7400
7401    let engines: &[DbEngine] = &[
7402        DbEngine {
7403            name: "PostgreSQL",
7404            service_names: &[
7405                "postgresql",
7406                "postgresql-x64-14",
7407                "postgresql-x64-15",
7408                "postgresql-x64-16",
7409                "postgresql-x64-17",
7410            ],
7411
7412            default_port: 5432,
7413            cli_name: "psql",
7414            cli_version_args: &["--version"],
7415        },
7416        DbEngine {
7417            name: "MySQL",
7418            service_names: &["mysql", "mysql80", "mysql57"],
7419
7420            default_port: 3306,
7421            cli_name: "mysql",
7422            cli_version_args: &["--version"],
7423        },
7424        DbEngine {
7425            name: "MariaDB",
7426            service_names: &["mariadb", "mariadb.exe"],
7427
7428            default_port: 3306,
7429            cli_name: "mariadb",
7430            cli_version_args: &["--version"],
7431        },
7432        DbEngine {
7433            name: "MongoDB",
7434            service_names: &["mongodb", "mongod"],
7435
7436            default_port: 27017,
7437            cli_name: "mongod",
7438            cli_version_args: &["--version"],
7439        },
7440        DbEngine {
7441            name: "Redis",
7442            service_names: &["redis", "redis-server"],
7443
7444            default_port: 6379,
7445            cli_name: "redis-server",
7446            cli_version_args: &["--version"],
7447        },
7448        DbEngine {
7449            name: "SQL Server",
7450            service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
7451
7452            default_port: 1433,
7453            cli_name: "sqlcmd",
7454            cli_version_args: &["-?"],
7455        },
7456        DbEngine {
7457            name: "SQLite",
7458            service_names: &[], // no service — file-based
7459
7460            default_port: 0, // no port — file-based
7461            cli_name: "sqlite3",
7462            cli_version_args: &["--version"],
7463        },
7464        DbEngine {
7465            name: "CouchDB",
7466            service_names: &["couchdb", "apache-couchdb"],
7467
7468            default_port: 5984,
7469            cli_name: "couchdb",
7470            cli_version_args: &["--version"],
7471        },
7472        DbEngine {
7473            name: "Cassandra",
7474            service_names: &["cassandra"],
7475
7476            default_port: 9042,
7477            cli_name: "cqlsh",
7478            cli_version_args: &["--version"],
7479        },
7480        DbEngine {
7481            name: "Elasticsearch",
7482            service_names: &["elasticsearch-service-x64", "elasticsearch"],
7483
7484            default_port: 9200,
7485            cli_name: "elasticsearch",
7486            cli_version_args: &["--version"],
7487        },
7488    ];
7489
7490    // Helper: check if port is listening
7491    fn port_listening(port: u16) -> bool {
7492        if port == 0 {
7493            return false;
7494        }
7495        // Use netstat-style check via connecting
7496        std::net::TcpStream::connect_timeout(
7497            &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7498            std::time::Duration::from_millis(150),
7499        )
7500        .is_ok()
7501    }
7502
7503    let mut found_any = false;
7504
7505    for engine in engines {
7506        let mut status_parts: Vec<String> = Vec::new();
7507        let mut detected = false;
7508
7509        // 1. CLI version check (fastest — works cross-platform)
7510        let version = Command::new(engine.cli_name)
7511            .args(engine.cli_version_args)
7512            .output()
7513            .ok()
7514            .and_then(|o| {
7515                let combined = if o.stdout.is_empty() {
7516                    String::from_utf8_lossy(&o.stderr).trim().to_string()
7517                } else {
7518                    String::from_utf8_lossy(&o.stdout).trim().to_string()
7519                };
7520                // Take just the first line
7521                combined.lines().next().map(|l| l.trim().to_string())
7522            });
7523
7524        if let Some(ref ver) = version {
7525            if !ver.is_empty() {
7526                status_parts.push(format!("version: {ver}"));
7527                detected = true;
7528            }
7529        }
7530
7531        // 2. Port check
7532        if engine.default_port > 0 && port_listening(engine.default_port) {
7533            status_parts.push(format!("listening on :{}", engine.default_port));
7534            detected = true;
7535        } else if engine.default_port > 0 && detected {
7536            status_parts.push(format!("not listening on :{}", engine.default_port));
7537        }
7538
7539        // 3. Windows service check
7540        #[cfg(target_os = "windows")]
7541        {
7542            if !engine.service_names.is_empty() {
7543                let service_list = engine.service_names.join("','");
7544                let script = format!(
7545                    r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
7546                    service_list
7547                );
7548                if let Ok(o) = Command::new("powershell")
7549                    .args(["-NoProfile", "-Command", &script])
7550                    .output()
7551                {
7552                    let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7553                    if !text.is_empty() {
7554                        let parts: Vec<&str> = text.splitn(2, ':').collect();
7555                        let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
7556                        let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
7557                        status_parts.push(format!("service '{svc_name}': {svc_state}"));
7558                        detected = true;
7559                    }
7560                }
7561            }
7562        }
7563
7564        // 4. Linux/macOS systemctl / launchctl check
7565        #[cfg(not(target_os = "windows"))]
7566        {
7567            for svc in engine.service_names {
7568                if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
7569                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
7570                    if !state.is_empty() && state != "inactive" {
7571                        status_parts.push(format!("systemd '{svc}': {state}"));
7572                        detected = true;
7573                        break;
7574                    }
7575                }
7576            }
7577        }
7578
7579        if detected {
7580            found_any = true;
7581            let label = if engine.default_port > 0 {
7582                format!("{} (default port: {})", engine.name, engine.default_port)
7583            } else {
7584                format!("{} (file-based, no port)", engine.name)
7585            };
7586            out.push_str(&format!("[FOUND] {label}\n"));
7587            for part in &status_parts {
7588                out.push_str(&format!("  {part}\n"));
7589            }
7590            out.push('\n');
7591        }
7592    }
7593
7594    if !found_any {
7595        out.push_str("No local database engines detected.\n");
7596        out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
7597        out.push_str(
7598            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
7599        );
7600    } else {
7601        out.push_str("---\n");
7602        out.push_str(
7603            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
7604        );
7605        out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
7606    }
7607
7608    Ok(out.trim_end().to_string())
7609}
7610
7611// ── user_accounts ─────────────────────────────────────────────────────────────
7612
7613fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
7614    let mut out = String::from("Host inspection: user_accounts\n\n");
7615
7616    #[cfg(target_os = "windows")]
7617    {
7618        let users_out = Command::new("powershell")
7619            .args([
7620                "-NoProfile", "-NonInteractive", "-Command",
7621                "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \"  $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
7622            ])
7623            .output()
7624            .ok()
7625            .and_then(|o| String::from_utf8(o.stdout).ok())
7626            .unwrap_or_default();
7627
7628        out.push_str("=== Local User Accounts ===\n");
7629        if users_out.trim().is_empty() {
7630            out.push_str("  (requires elevation or Get-LocalUser unavailable)\n");
7631        } else {
7632            for line in users_out.lines().take(max_entries) {
7633                if !line.trim().is_empty() {
7634                    out.push_str(line);
7635                    out.push('\n');
7636                }
7637            }
7638        }
7639
7640        let admins_out = Command::new("powershell")
7641            .args([
7642                "-NoProfile", "-NonInteractive", "-Command",
7643                "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \"  $($_.ObjectClass): $($_.Name)\" }",
7644            ])
7645            .output()
7646            .ok()
7647            .and_then(|o| String::from_utf8(o.stdout).ok())
7648            .unwrap_or_default();
7649
7650        out.push_str("\n=== Administrators Group Members ===\n");
7651        if admins_out.trim().is_empty() {
7652            out.push_str("  (unable to retrieve)\n");
7653        } else {
7654            out.push_str(admins_out.trim());
7655            out.push('\n');
7656        }
7657
7658        let sessions_out = Command::new("powershell")
7659            .args([
7660                "-NoProfile",
7661                "-NonInteractive",
7662                "-Command",
7663                "query user 2>$null",
7664            ])
7665            .output()
7666            .ok()
7667            .and_then(|o| String::from_utf8(o.stdout).ok())
7668            .unwrap_or_default();
7669
7670        out.push_str("\n=== Active Logon Sessions ===\n");
7671        if sessions_out.trim().is_empty() {
7672            out.push_str("  (none or requires elevation)\n");
7673        } else {
7674            for line in sessions_out.lines().take(max_entries) {
7675                if !line.trim().is_empty() {
7676                    out.push_str(&format!("  {}\n", line));
7677                }
7678            }
7679        }
7680
7681        let is_admin = Command::new("powershell")
7682            .args([
7683                "-NoProfile", "-NonInteractive", "-Command",
7684                "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
7685            ])
7686            .output()
7687            .ok()
7688            .and_then(|o| String::from_utf8(o.stdout).ok())
7689            .map(|s| s.trim().to_lowercase())
7690            .unwrap_or_default();
7691
7692        out.push_str("\n=== Current Session Elevation ===\n");
7693        out.push_str(&format!(
7694            "  Running as Administrator: {}\n",
7695            if is_admin.contains("true") {
7696                "YES"
7697            } else {
7698                "no"
7699            }
7700        ));
7701    }
7702
7703    #[cfg(not(target_os = "windows"))]
7704    {
7705        let who_out = Command::new("who")
7706            .output()
7707            .ok()
7708            .and_then(|o| String::from_utf8(o.stdout).ok())
7709            .unwrap_or_default();
7710        out.push_str("=== Active Sessions ===\n");
7711        if who_out.trim().is_empty() {
7712            out.push_str("  (none)\n");
7713        } else {
7714            for line in who_out.lines().take(max_entries) {
7715                out.push_str(&format!("  {}\n", line));
7716            }
7717        }
7718        let id_out = Command::new("id")
7719            .output()
7720            .ok()
7721            .and_then(|o| String::from_utf8(o.stdout).ok())
7722            .unwrap_or_default();
7723        out.push_str(&format!("\n=== Current User ===\n  {}\n", id_out.trim()));
7724    }
7725
7726    Ok(out.trim_end().to_string())
7727}
7728
7729// ── audit_policy ──────────────────────────────────────────────────────────────
7730
7731fn inspect_audit_policy() -> Result<String, String> {
7732    let mut out = String::from("Host inspection: audit_policy\n\n");
7733
7734    #[cfg(target_os = "windows")]
7735    {
7736        let auditpol_out = Command::new("auditpol")
7737            .args(["/get", "/category:*"])
7738            .output()
7739            .ok()
7740            .and_then(|o| String::from_utf8(o.stdout).ok())
7741            .unwrap_or_default();
7742
7743        if auditpol_out.trim().is_empty()
7744            || auditpol_out.to_lowercase().contains("access is denied")
7745        {
7746            out.push_str("Audit policy requires Administrator elevation to read.\n");
7747            out.push_str(
7748                "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
7749            );
7750        } else {
7751            out.push_str("=== Windows Audit Policy ===\n");
7752            let mut any_enabled = false;
7753            for line in auditpol_out.lines() {
7754                let trimmed = line.trim();
7755                if trimmed.is_empty() {
7756                    continue;
7757                }
7758                if trimmed.contains("Success") || trimmed.contains("Failure") {
7759                    out.push_str(&format!("  [ENABLED] {}\n", trimmed));
7760                    any_enabled = true;
7761                } else {
7762                    out.push_str(&format!("  {}\n", trimmed));
7763                }
7764            }
7765            if !any_enabled {
7766                out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
7767                out.push_str(
7768                    "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
7769                );
7770            }
7771        }
7772
7773        let evtlog = Command::new("powershell")
7774            .args([
7775                "-NoProfile", "-NonInteractive", "-Command",
7776                "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
7777            ])
7778            .output()
7779            .ok()
7780            .and_then(|o| String::from_utf8(o.stdout).ok())
7781            .map(|s| s.trim().to_string())
7782            .unwrap_or_default();
7783
7784        out.push_str(&format!(
7785            "\n=== Windows Event Log Service ===\n  Status: {}\n",
7786            if evtlog.is_empty() {
7787                "unknown".to_string()
7788            } else {
7789                evtlog
7790            }
7791        ));
7792    }
7793
7794    #[cfg(not(target_os = "windows"))]
7795    {
7796        let auditd_status = Command::new("systemctl")
7797            .args(["is-active", "auditd"])
7798            .output()
7799            .ok()
7800            .and_then(|o| String::from_utf8(o.stdout).ok())
7801            .map(|s| s.trim().to_string())
7802            .unwrap_or_else(|| "not found".to_string());
7803
7804        out.push_str(&format!(
7805            "=== auditd service ===\n  Status: {}\n",
7806            auditd_status
7807        ));
7808
7809        if auditd_status == "active" {
7810            let rules = Command::new("auditctl")
7811                .args(["-l"])
7812                .output()
7813                .ok()
7814                .and_then(|o| String::from_utf8(o.stdout).ok())
7815                .unwrap_or_default();
7816            out.push_str("\n=== Active Audit Rules ===\n");
7817            if rules.trim().is_empty() || rules.contains("No rules") {
7818                out.push_str("  No rules configured.\n");
7819            } else {
7820                for line in rules.lines() {
7821                    out.push_str(&format!("  {}\n", line));
7822                }
7823            }
7824        }
7825    }
7826
7827    Ok(out.trim_end().to_string())
7828}
7829
7830// ── shares ────────────────────────────────────────────────────────────────────
7831
7832fn inspect_shares(max_entries: usize) -> Result<String, String> {
7833    let mut out = String::from("Host inspection: shares\n\n");
7834
7835    #[cfg(target_os = "windows")]
7836    {
7837        let smb_out = Command::new("powershell")
7838            .args([
7839                "-NoProfile", "-NonInteractive", "-Command",
7840                "Get-SmbShare | ForEach-Object { \"  $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
7841            ])
7842            .output()
7843            .ok()
7844            .and_then(|o| String::from_utf8(o.stdout).ok())
7845            .unwrap_or_default();
7846
7847        out.push_str("=== SMB Shares (exposed by this machine) ===\n");
7848        let smb_lines: Vec<&str> = smb_out
7849            .lines()
7850            .filter(|l| !l.trim().is_empty())
7851            .take(max_entries)
7852            .collect();
7853        if smb_lines.is_empty() {
7854            out.push_str("  No SMB shares or unable to retrieve.\n");
7855        } else {
7856            for line in &smb_lines {
7857                let name = line.trim().split('|').next().unwrap_or("").trim();
7858                if name.ends_with('$') {
7859                    out.push_str(&format!("  {}\n", line.trim()));
7860                } else {
7861                    out.push_str(&format!("  [CUSTOM] {}\n", line.trim()));
7862                }
7863            }
7864        }
7865
7866        let smb_security = Command::new("powershell")
7867            .args([
7868                "-NoProfile", "-NonInteractive", "-Command",
7869                "Get-SmbServerConfiguration | ForEach-Object { \"  SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
7870            ])
7871            .output()
7872            .ok()
7873            .and_then(|o| String::from_utf8(o.stdout).ok())
7874            .unwrap_or_default();
7875
7876        out.push_str("\n=== SMB Server Security Settings ===\n");
7877        if smb_security.trim().is_empty() {
7878            out.push_str("  (unable to retrieve)\n");
7879        } else {
7880            out.push_str(smb_security.trim());
7881            out.push('\n');
7882            if smb_security.to_lowercase().contains("smb1: true") {
7883                out.push_str("  [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
7884            }
7885        }
7886
7887        let drives_out = Command::new("powershell")
7888            .args([
7889                "-NoProfile", "-NonInteractive", "-Command",
7890                "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \"  $($_.Name): -> $($_.DisplayRoot)\" }",
7891            ])
7892            .output()
7893            .ok()
7894            .and_then(|o| String::from_utf8(o.stdout).ok())
7895            .unwrap_or_default();
7896
7897        out.push_str("\n=== Mapped Network Drives ===\n");
7898        if drives_out.trim().is_empty() {
7899            out.push_str("  None.\n");
7900        } else {
7901            for line in drives_out.lines().take(max_entries) {
7902                if !line.trim().is_empty() {
7903                    out.push_str(line);
7904                    out.push('\n');
7905                }
7906            }
7907        }
7908    }
7909
7910    #[cfg(not(target_os = "windows"))]
7911    {
7912        let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
7913        out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
7914        if smb_conf.is_empty() {
7915            out.push_str("  Not found or Samba not installed.\n");
7916        } else {
7917            for line in smb_conf.lines().take(max_entries) {
7918                out.push_str(&format!("  {}\n", line));
7919            }
7920        }
7921        let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
7922        out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
7923        if nfs_exports.is_empty() {
7924            out.push_str("  Not configured.\n");
7925        } else {
7926            for line in nfs_exports.lines().take(max_entries) {
7927                out.push_str(&format!("  {}\n", line));
7928            }
7929        }
7930    }
7931
7932    Ok(out.trim_end().to_string())
7933}
7934
7935// ── dns_servers ───────────────────────────────────────────────────────────────
7936
7937fn inspect_dns_servers() -> Result<String, String> {
7938    let mut out = String::from("Host inspection: dns_servers\n\n");
7939
7940    #[cfg(target_os = "windows")]
7941    {
7942        let dns_out = Command::new("powershell")
7943            .args([
7944                "-NoProfile", "-NonInteractive", "-Command",
7945                "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \"  $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
7946            ])
7947            .output()
7948            .ok()
7949            .and_then(|o| String::from_utf8(o.stdout).ok())
7950            .unwrap_or_default();
7951
7952        out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
7953        if dns_out.trim().is_empty() {
7954            out.push_str("  (unable to retrieve)\n");
7955        } else {
7956            for line in dns_out.lines() {
7957                if line.trim().is_empty() {
7958                    continue;
7959                }
7960                let mut annotation = "";
7961                if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
7962                    annotation = "  <- Google Public DNS";
7963                } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
7964                    annotation = "  <- Cloudflare DNS";
7965                } else if line.contains("9.9.9.9") {
7966                    annotation = "  <- Quad9";
7967                } else if line.contains("208.67.222") || line.contains("208.67.220") {
7968                    annotation = "  <- OpenDNS";
7969                }
7970                out.push_str(line);
7971                out.push_str(annotation);
7972                out.push('\n');
7973            }
7974        }
7975
7976        let doh_out = Command::new("powershell")
7977            .args([
7978                "-NoProfile", "-NonInteractive", "-Command",
7979                "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \"  $($_.ServerAddress): $($_.DohTemplate)\" }",
7980            ])
7981            .output()
7982            .ok()
7983            .and_then(|o| String::from_utf8(o.stdout).ok())
7984            .unwrap_or_default();
7985
7986        out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
7987        if doh_out.trim().is_empty() {
7988            out.push_str("  Not configured (plain DNS).\n");
7989        } else {
7990            out.push_str(doh_out.trim());
7991            out.push('\n');
7992        }
7993
7994        let suffixes = Command::new("powershell")
7995            .args([
7996                "-NoProfile", "-NonInteractive", "-Command",
7997                "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \"  $_\" }",
7998            ])
7999            .output()
8000            .ok()
8001            .and_then(|o| String::from_utf8(o.stdout).ok())
8002            .unwrap_or_default();
8003
8004        if !suffixes.trim().is_empty() {
8005            out.push_str("\n=== DNS Search Suffix List ===\n");
8006            out.push_str(suffixes.trim());
8007            out.push('\n');
8008        }
8009    }
8010
8011    #[cfg(not(target_os = "windows"))]
8012    {
8013        let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
8014        out.push_str("=== /etc/resolv.conf ===\n");
8015        if resolv.is_empty() {
8016            out.push_str("  Not found.\n");
8017        } else {
8018            for line in resolv.lines() {
8019                if !line.trim().is_empty() && !line.starts_with('#') {
8020                    out.push_str(&format!("  {}\n", line));
8021                }
8022            }
8023        }
8024        let resolved_out = Command::new("resolvectl")
8025            .args(["status", "--no-pager"])
8026            .output()
8027            .ok()
8028            .and_then(|o| String::from_utf8(o.stdout).ok())
8029            .unwrap_or_default();
8030        if !resolved_out.is_empty() {
8031            out.push_str("\n=== systemd-resolved ===\n");
8032            for line in resolved_out.lines().take(30) {
8033                out.push_str(&format!("  {}\n", line));
8034            }
8035        }
8036    }
8037
8038    Ok(out.trim_end().to_string())
8039}
8040
8041fn inspect_bitlocker() -> Result<String, String> {
8042    let mut out = String::from("Host inspection: bitlocker\n\n");
8043
8044    #[cfg(target_os = "windows")]
8045    {
8046        let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
8047        let output = Command::new("powershell")
8048            .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
8049            .output()
8050            .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
8051
8052        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
8053        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
8054
8055        if !stdout.trim().is_empty() {
8056            out.push_str("=== BitLocker Volumes ===\n");
8057            for line in stdout.lines() {
8058                out.push_str(&format!("  {}\n", line));
8059            }
8060        } else if !stderr.trim().is_empty() {
8061            if stderr.contains("Access is denied") {
8062                out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
8063            } else {
8064                out.push_str(&format!(
8065                    "Error retrieving BitLocker info: {}\n",
8066                    stderr.trim()
8067                ));
8068            }
8069        } else {
8070            out.push_str("No BitLocker volumes detected or access denied.\n");
8071        }
8072    }
8073
8074    #[cfg(not(target_os = "windows"))]
8075    {
8076        out.push_str(
8077            "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
8078        );
8079        let lsblk = Command::new("lsblk")
8080            .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
8081            .output()
8082            .ok()
8083            .and_then(|o| String::from_utf8(o.stdout).ok())
8084            .unwrap_or_default();
8085        if lsblk.contains("crypto_LUKS") {
8086            out.push_str("=== LUKS Encrypted Volumes ===\n");
8087            for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
8088                out.push_str(&format!("  {}\n", line));
8089            }
8090        } else {
8091            out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
8092        }
8093    }
8094
8095    Ok(out.trim_end().to_string())
8096}
8097
8098fn inspect_rdp() -> Result<String, String> {
8099    let mut out = String::from("Host inspection: rdp\n\n");
8100
8101    #[cfg(target_os = "windows")]
8102    {
8103        let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
8104        let f_deny = Command::new("powershell")
8105            .args([
8106                "-NoProfile",
8107                "-Command",
8108                &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
8109            ])
8110            .output()
8111            .ok()
8112            .and_then(|o| String::from_utf8(o.stdout).ok())
8113            .unwrap_or_default()
8114            .trim()
8115            .to_string();
8116
8117        let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
8118        out.push_str(&format!("=== RDP Status: {} ===\n", status));
8119
8120        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"])
8121            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
8122        out.push_str(&format!(
8123            "  Port: {}\n",
8124            if port.is_empty() {
8125                "3389 (default)"
8126            } else {
8127                &port
8128            }
8129        ));
8130
8131        let nla = Command::new("powershell")
8132            .args([
8133                "-NoProfile",
8134                "-Command",
8135                &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
8136            ])
8137            .output()
8138            .ok()
8139            .and_then(|o| String::from_utf8(o.stdout).ok())
8140            .unwrap_or_default()
8141            .trim()
8142            .to_string();
8143        out.push_str(&format!(
8144            "  NLA Required: {}\n",
8145            if nla == "1" { "Yes" } else { "No" }
8146        ));
8147
8148        out.push_str("\n=== Active Sessions ===\n");
8149        let qwinsta = Command::new("qwinsta")
8150            .output()
8151            .ok()
8152            .and_then(|o| String::from_utf8(o.stdout).ok())
8153            .unwrap_or_default();
8154        if qwinsta.trim().is_empty() {
8155            out.push_str("  No active sessions listed.\n");
8156        } else {
8157            for line in qwinsta.lines() {
8158                out.push_str(&format!("  {}\n", line));
8159            }
8160        }
8161
8162        out.push_str("\n=== Firewall Rule Check ===\n");
8163        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))\" }"])
8164            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8165        if fw.trim().is_empty() {
8166            out.push_str("  No enabled RDP firewall rules found.\n");
8167        } else {
8168            out.push_str(fw.trim_end());
8169            out.push('\n');
8170        }
8171    }
8172
8173    #[cfg(not(target_os = "windows"))]
8174    {
8175        out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
8176        let ss = Command::new("ss")
8177            .args(["-tlnp"])
8178            .output()
8179            .ok()
8180            .and_then(|o| String::from_utf8(o.stdout).ok())
8181            .unwrap_or_default();
8182        let matches: Vec<&str> = ss
8183            .lines()
8184            .filter(|l| l.contains(":3389") || l.contains(":590"))
8185            .collect();
8186        if matches.is_empty() {
8187            out.push_str("  No RDP/VNC listeners detected via 'ss'.\n");
8188        } else {
8189            for m in matches {
8190                out.push_str(&format!("  {}\n", m));
8191            }
8192        }
8193    }
8194
8195    Ok(out.trim_end().to_string())
8196}
8197
8198fn inspect_shadow_copies() -> Result<String, String> {
8199    let mut out = String::from("Host inspection: shadow_copies\n\n");
8200
8201    #[cfg(target_os = "windows")]
8202    {
8203        let output = Command::new("vssadmin")
8204            .args(["list", "shadows"])
8205            .output()
8206            .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
8207        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
8208
8209        if stdout.contains("No items found") || stdout.trim().is_empty() {
8210            out.push_str("No Volume Shadow Copies found.\n");
8211        } else {
8212            out.push_str("=== Volume Shadow Copies ===\n");
8213            for line in stdout.lines().take(50) {
8214                if line.contains("Creation Time:")
8215                    || line.contains("Contents:")
8216                    || line.contains("Volume Name:")
8217                {
8218                    out.push_str(&format!("  {}\n", line.trim()));
8219                }
8220            }
8221        }
8222
8223        out.push_str("\n=== Shadow Copy Storage ===\n");
8224        let storage_out = Command::new("vssadmin")
8225            .args(["list", "shadowstorage"])
8226            .output()
8227            .ok();
8228        if let Some(o) = storage_out {
8229            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8230            for line in stdout.lines() {
8231                if line.contains("Used Shadow Copy Storage space:")
8232                    || line.contains("Max Shadow Copy Storage space:")
8233                {
8234                    out.push_str(&format!("  {}\n", line.trim()));
8235                }
8236            }
8237        }
8238    }
8239
8240    #[cfg(not(target_os = "windows"))]
8241    {
8242        out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
8243        let lvs = Command::new("lvs")
8244            .output()
8245            .ok()
8246            .and_then(|o| String::from_utf8(o.stdout).ok())
8247            .unwrap_or_default();
8248        if !lvs.is_empty() {
8249            out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
8250            out.push_str(&lvs);
8251        } else {
8252            out.push_str("No LVM volumes detected.\n");
8253        }
8254    }
8255
8256    Ok(out.trim_end().to_string())
8257}
8258
8259fn inspect_pagefile() -> Result<String, String> {
8260    let mut out = String::from("Host inspection: pagefile\n\n");
8261
8262    #[cfg(target_os = "windows")]
8263    {
8264        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)\" }";
8265        let output = Command::new("powershell")
8266            .args(["-NoProfile", "-Command", ps_cmd])
8267            .output()
8268            .ok()
8269            .and_then(|o| String::from_utf8(o.stdout).ok())
8270            .unwrap_or_default();
8271
8272        if output.trim().is_empty() {
8273            out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
8274            let managed = Command::new("powershell")
8275                .args([
8276                    "-NoProfile",
8277                    "-Command",
8278                    "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
8279                ])
8280                .output()
8281                .ok()
8282                .and_then(|o| String::from_utf8(o.stdout).ok())
8283                .unwrap_or_default()
8284                .trim()
8285                .to_string();
8286            out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
8287        } else {
8288            out.push_str("=== Page File Usage ===\n");
8289            out.push_str(&output);
8290        }
8291    }
8292
8293    #[cfg(not(target_os = "windows"))]
8294    {
8295        out.push_str("=== Swap Usage (Linux/macOS) ===\n");
8296        let swap = Command::new("swapon")
8297            .args(["--show"])
8298            .output()
8299            .ok()
8300            .and_then(|o| String::from_utf8(o.stdout).ok())
8301            .unwrap_or_default();
8302        if swap.is_empty() {
8303            let free = Command::new("free")
8304                .args(["-h"])
8305                .output()
8306                .ok()
8307                .and_then(|o| String::from_utf8(o.stdout).ok())
8308                .unwrap_or_default();
8309            out.push_str(&free);
8310        } else {
8311            out.push_str(&swap);
8312        }
8313    }
8314
8315    Ok(out.trim_end().to_string())
8316}
8317
8318fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
8319    let mut out = String::from("Host inspection: windows_features\n\n");
8320
8321    #[cfg(target_os = "windows")]
8322    {
8323        out.push_str("=== Quick Check: Notable Features ===\n");
8324        let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
8325        let output = Command::new("powershell")
8326            .args(["-NoProfile", "-Command", quick_ps])
8327            .output()
8328            .ok();
8329
8330        if let Some(o) = output {
8331            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8332            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8333
8334            if !stdout.trim().is_empty() {
8335                for f in stdout.lines() {
8336                    out.push_str(&format!("  [ENABLED] {}\n", f));
8337                }
8338            } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
8339                out.push_str("  Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
8340            } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
8341                out.push_str(
8342                    "  No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
8343                );
8344            }
8345        }
8346
8347        out.push_str(&format!(
8348            "\n=== All Enabled Features (capped at {}) ===\n",
8349            max_entries
8350        ));
8351        let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
8352        let all_out = Command::new("powershell")
8353            .args(["-NoProfile", "-Command", &all_ps])
8354            .output()
8355            .ok();
8356        if let Some(o) = all_out {
8357            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8358            if !stdout.trim().is_empty() {
8359                out.push_str(&stdout);
8360            }
8361        }
8362    }
8363
8364    #[cfg(not(target_os = "windows"))]
8365    {
8366        let _ = max_entries;
8367        out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
8368    }
8369
8370    Ok(out.trim_end().to_string())
8371}
8372
8373fn inspect_printers(max_entries: usize) -> Result<String, String> {
8374    let mut out = String::from("Host inspection: printers\n\n");
8375
8376    #[cfg(target_os = "windows")]
8377    {
8378        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)])
8379            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8380        if list.trim().is_empty() {
8381            out.push_str("No printers detected.\n");
8382        } else {
8383            out.push_str("=== Installed Printers ===\n");
8384            out.push_str(&list);
8385        }
8386
8387        let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \"  [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
8388            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8389        if !jobs.trim().is_empty() {
8390            out.push_str("\n=== Active Print Jobs ===\n");
8391            out.push_str(&jobs);
8392        }
8393    }
8394
8395    #[cfg(not(target_os = "windows"))]
8396    {
8397        let _ = max_entries;
8398        out.push_str("Checking LPSTAT for printers...\n");
8399        let lpstat = Command::new("lpstat")
8400            .args(["-p", "-d"])
8401            .output()
8402            .ok()
8403            .and_then(|o| String::from_utf8(o.stdout).ok())
8404            .unwrap_or_default();
8405        if lpstat.is_empty() {
8406            out.push_str("  No CUPS/LP printers found.\n");
8407        } else {
8408            out.push_str(&lpstat);
8409        }
8410    }
8411
8412    Ok(out.trim_end().to_string())
8413}
8414
8415fn inspect_winrm() -> Result<String, String> {
8416    let mut out = String::from("Host inspection: winrm\n\n");
8417
8418    #[cfg(target_os = "windows")]
8419    {
8420        let svc = Command::new("powershell")
8421            .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
8422            .output()
8423            .ok()
8424            .and_then(|o| String::from_utf8(o.stdout).ok())
8425            .unwrap_or_default()
8426            .trim()
8427            .to_string();
8428        out.push_str(&format!(
8429            "WinRM Service Status: {}\n\n",
8430            if svc.is_empty() { "NOT_FOUND" } else { &svc }
8431        ));
8432
8433        out.push_str("=== WinRM Listeners ===\n");
8434        let output = Command::new("powershell")
8435            .args([
8436                "-NoProfile",
8437                "-Command",
8438                "winrm enumerate winrm/config/listener 2>$null",
8439            ])
8440            .output()
8441            .ok();
8442        if let Some(o) = output {
8443            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8444            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8445
8446            if !stdout.trim().is_empty() {
8447                for line in stdout.lines() {
8448                    if line.contains("Address =")
8449                        || line.contains("Transport =")
8450                        || line.contains("Port =")
8451                    {
8452                        out.push_str(&format!("  {}\n", line.trim()));
8453                    }
8454                }
8455            } else if stderr.contains("Access is denied") {
8456                out.push_str("  Error: Access denied to WinRM configuration.\n");
8457            } else {
8458                out.push_str("  No listeners configured.\n");
8459            }
8460        }
8461
8462        out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
8463        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))\" }"])
8464            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8465        if test_out.trim().is_empty() {
8466            out.push_str("  WinRM not responding to local WS-Man requests.\n");
8467        } else {
8468            out.push_str(&test_out);
8469        }
8470    }
8471
8472    #[cfg(not(target_os = "windows"))]
8473    {
8474        out.push_str(
8475            "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
8476        );
8477        let ss = Command::new("ss")
8478            .args(["-tln"])
8479            .output()
8480            .ok()
8481            .and_then(|o| String::from_utf8(o.stdout).ok())
8482            .unwrap_or_default();
8483        if ss.contains(":5985") || ss.contains(":5986") {
8484            out.push_str("  WinRM ports (5985/5986) are listening.\n");
8485        } else {
8486            out.push_str("  WinRM ports not detected.\n");
8487        }
8488    }
8489
8490    Ok(out.trim_end().to_string())
8491}
8492
8493fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
8494    let mut out = String::from("Host inspection: network_stats\n\n");
8495
8496    #[cfg(target_os = "windows")]
8497    {
8498        let ps_cmd = format!(
8499            "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
8500             Start-Sleep -Milliseconds 250; \
8501             $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
8502             $s2 | ForEach-Object {{ \
8503                $name = $_.Name; \
8504                $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
8505                if ($prev) {{ \
8506                    $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
8507                    $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
8508                    $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
8509                    $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
8510                    $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
8511                    $tt = [math]::Round($_.SentBytes / 1MB, 2); \
8512                    \"  $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
8513                }} \
8514             }}",
8515            max_entries
8516        );
8517        let output = Command::new("powershell")
8518            .args(["-NoProfile", "-Command", &ps_cmd])
8519            .output()
8520            .ok()
8521            .and_then(|o| String::from_utf8(o.stdout).ok())
8522            .unwrap_or_default();
8523        if output.trim().is_empty() {
8524            out.push_str("No network adapter statistics available.\n");
8525        } else {
8526            out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
8527            out.push_str(&output);
8528        }
8529
8530        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)\" } }"])
8531            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8532        if !discards.trim().is_empty() {
8533            out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
8534            out.push_str(&discards);
8535        }
8536    }
8537
8538    #[cfg(not(target_os = "windows"))]
8539    {
8540        let _ = max_entries;
8541        out.push_str("=== Network Stats (ip -s link) ===\n");
8542        let ip_s = Command::new("ip")
8543            .args(["-s", "link"])
8544            .output()
8545            .ok()
8546            .and_then(|o| String::from_utf8(o.stdout).ok())
8547            .unwrap_or_default();
8548        if ip_s.is_empty() {
8549            let netstat = Command::new("netstat")
8550                .args(["-i"])
8551                .output()
8552                .ok()
8553                .and_then(|o| String::from_utf8(o.stdout).ok())
8554                .unwrap_or_default();
8555            out.push_str(&netstat);
8556        } else {
8557            out.push_str(&ip_s);
8558        }
8559    }
8560
8561    Ok(out.trim_end().to_string())
8562}
8563
8564fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
8565    let mut out = String::from("Host inspection: udp_ports\n\n");
8566
8567    #[cfg(target_os = "windows")]
8568    {
8569        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);
8570        let output = Command::new("powershell")
8571            .args(["-NoProfile", "-Command", &ps_cmd])
8572            .output()
8573            .ok();
8574
8575        if let Some(o) = output {
8576            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8577            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8578
8579            if !stdout.trim().is_empty() {
8580                out.push_str("=== UDP Listeners (Local:Port) ===\n");
8581                for line in stdout.lines() {
8582                    let mut note = "";
8583                    if line.contains(":53 ") {
8584                        note = " [DNS]";
8585                    } else if line.contains(":67 ") || line.contains(":68 ") {
8586                        note = " [DHCP]";
8587                    } else if line.contains(":123 ") {
8588                        note = " [NTP]";
8589                    } else if line.contains(":161 ") {
8590                        note = " [SNMP]";
8591                    } else if line.contains(":1900 ") {
8592                        note = " [SSDP/UPnP]";
8593                    } else if line.contains(":5353 ") {
8594                        note = " [mDNS]";
8595                    }
8596
8597                    out.push_str(&format!("{}{}\n", line, note));
8598                }
8599            } else if stderr.contains("Access is denied") {
8600                out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
8601            } else {
8602                out.push_str("No UDP listeners detected.\n");
8603            }
8604        }
8605    }
8606
8607    #[cfg(not(target_os = "windows"))]
8608    {
8609        let ss_out = Command::new("ss")
8610            .args(["-ulnp"])
8611            .output()
8612            .ok()
8613            .and_then(|o| String::from_utf8(o.stdout).ok())
8614            .unwrap_or_default();
8615        out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
8616        if ss_out.is_empty() {
8617            let netstat_out = Command::new("netstat")
8618                .args(["-ulnp"])
8619                .output()
8620                .ok()
8621                .and_then(|o| String::from_utf8(o.stdout).ok())
8622                .unwrap_or_default();
8623            if netstat_out.is_empty() {
8624                out.push_str("  Neither 'ss' nor 'netstat' available.\n");
8625            } else {
8626                for line in netstat_out.lines().take(max_entries) {
8627                    out.push_str(&format!("  {}\n", line));
8628                }
8629            }
8630        } else {
8631            for line in ss_out.lines().take(max_entries) {
8632                out.push_str(&format!("  {}\n", line));
8633            }
8634        }
8635    }
8636
8637    Ok(out.trim_end().to_string())
8638}
8639
8640fn inspect_gpo() -> Result<String, String> {
8641    let mut out = String::from("Host inspection: gpo\n\n");
8642
8643    #[cfg(target_os = "windows")]
8644    {
8645        let output = Command::new("gpresult")
8646            .args(["/r", "/scope", "computer"])
8647            .output()
8648            .ok();
8649
8650        if let Some(o) = output {
8651            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8652            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8653
8654            if stdout.contains("Applied Group Policy Objects") {
8655                out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
8656                let mut capture = false;
8657                for line in stdout.lines() {
8658                    if line.contains("Applied Group Policy Objects") {
8659                        capture = true;
8660                    } else if capture && line.contains("The following GPOs were not applied") {
8661                        break;
8662                    }
8663                    if capture && !line.trim().is_empty() {
8664                        out.push_str(&format!("  {}\n", line.trim()));
8665                    }
8666                }
8667            } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
8668                out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
8669            } else {
8670                out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
8671            }
8672        }
8673    }
8674
8675    #[cfg(not(target_os = "windows"))]
8676    {
8677        out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
8678    }
8679
8680    Ok(out.trim_end().to_string())
8681}
8682
8683fn inspect_certificates(max_entries: usize) -> Result<String, String> {
8684    let mut out = String::from("Host inspection: certificates\n\n");
8685
8686    #[cfg(target_os = "windows")]
8687    {
8688        let ps_cmd = format!(
8689            "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
8690                $days = ($_.NotAfter - (Get-Date)).Days; \
8691                $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
8692                \"  $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
8693            }}", 
8694            max_entries
8695        );
8696        let output = Command::new("powershell")
8697            .args(["-NoProfile", "-Command", &ps_cmd])
8698            .output()
8699            .ok();
8700
8701        if let Some(o) = output {
8702            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8703            if !stdout.trim().is_empty() {
8704                out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
8705                out.push_str(&stdout);
8706            } else {
8707                out.push_str("No certificates found in the Local Machine Personal store.\n");
8708            }
8709        }
8710    }
8711
8712    #[cfg(not(target_os = "windows"))]
8713    {
8714        let _ = max_entries;
8715        out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
8716        // Check standard cert locations
8717        for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
8718            if Path::new(path).exists() {
8719                out.push_str(&format!("  Cert directory found: {}\n", path));
8720            }
8721        }
8722    }
8723
8724    Ok(out.trim_end().to_string())
8725}
8726
8727fn inspect_integrity() -> Result<String, String> {
8728    let mut out = String::from("Host inspection: integrity\n\n");
8729
8730    #[cfg(target_os = "windows")]
8731    {
8732        let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
8733        let output = Command::new("powershell")
8734            .args(["-NoProfile", "-Command", &ps_cmd])
8735            .output()
8736            .ok();
8737
8738        if let Some(o) = output {
8739            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8740            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
8741                out.push_str("=== Windows Component Store Health (CBS) ===\n");
8742                let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
8743                let repair = val
8744                    .get("AutoRepairNeeded")
8745                    .and_then(|v| v.as_u64())
8746                    .unwrap_or(0);
8747
8748                out.push_str(&format!(
8749                    "  Corruption Detected: {}\n",
8750                    if corrupt != 0 {
8751                        "YES (SFC/DISM recommended)"
8752                    } else {
8753                        "No"
8754                    }
8755                ));
8756                out.push_str(&format!(
8757                    "  Auto-Repair Needed: {}\n",
8758                    if repair != 0 { "YES" } else { "No" }
8759                ));
8760
8761                if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
8762                    out.push_str(&format!("  Last Repair Attempt: (Raw code: {})\n", last));
8763                }
8764            } else {
8765                out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
8766            }
8767        }
8768
8769        if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
8770            out.push_str(
8771                "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
8772            );
8773        }
8774    }
8775
8776    #[cfg(not(target_os = "windows"))]
8777    {
8778        out.push_str("System integrity check (Linux)\n\n");
8779        let pkg_check = Command::new("rpm")
8780            .args(["-Va"])
8781            .output()
8782            .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
8783            .ok();
8784        if let Some(o) = pkg_check {
8785            out.push_str("  Package verification system active.\n");
8786            if o.status.success() {
8787                out.push_str("  No major package integrity issues detected.\n");
8788            }
8789        }
8790    }
8791
8792    Ok(out.trim_end().to_string())
8793}
8794
8795fn inspect_domain() -> Result<String, String> {
8796    let mut out = String::from("Host inspection: domain\n\n");
8797
8798    #[cfg(target_os = "windows")]
8799    {
8800        let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
8801        let output = Command::new("powershell")
8802            .args(["-NoProfile", "-Command", &ps_cmd])
8803            .output()
8804            .ok();
8805
8806        if let Some(o) = output {
8807            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8808            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
8809                let part_of_domain = val
8810                    .get("PartOfDomain")
8811                    .and_then(|v| v.as_bool())
8812                    .unwrap_or(false);
8813                let domain = val
8814                    .get("Domain")
8815                    .and_then(|v| v.as_str())
8816                    .unwrap_or("Unknown");
8817                let workgroup = val
8818                    .get("Workgroup")
8819                    .and_then(|v| v.as_str())
8820                    .unwrap_or("Unknown");
8821
8822                out.push_str("=== Windows Domain / Workgroup Identity ===\n");
8823                out.push_str(&format!(
8824                    "  Join Status: {}\n",
8825                    if part_of_domain {
8826                        "DOMAIN JOINED"
8827                    } else {
8828                        "WORKGROUP"
8829                    }
8830                ));
8831                if part_of_domain {
8832                    out.push_str(&format!("  Active Directory Domain: {}\n", domain));
8833                } else {
8834                    out.push_str(&format!("  Workgroup Name: {}\n", workgroup));
8835                }
8836
8837                if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
8838                    out.push_str(&format!("  NetBIOS Name: {}\n", name));
8839                }
8840            }
8841        }
8842    }
8843
8844    #[cfg(not(target_os = "windows"))]
8845    {
8846        let domainname = Command::new("domainname")
8847            .output()
8848            .ok()
8849            .and_then(|o| String::from_utf8(o.stdout).ok())
8850            .unwrap_or_default();
8851        out.push_str("=== Linux Domain Identity ===\n");
8852        if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
8853            out.push_str(&format!("  NIS/YP Domain: {}\n", domainname.trim()));
8854        } else {
8855            out.push_str("  No NIS domain configured.\n");
8856        }
8857    }
8858
8859    Ok(out.trim_end().to_string())
8860}
8861
8862fn inspect_device_health() -> Result<String, String> {
8863    let mut out = String::from("Host inspection: device_health\n\n");
8864
8865    #[cfg(target_os = "windows")]
8866    {
8867        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)\" }";
8868        let output = Command::new("powershell")
8869            .args(["-NoProfile", "-Command", ps_cmd])
8870            .output()
8871            .ok()
8872            .and_then(|o| String::from_utf8(o.stdout).ok())
8873            .unwrap_or_default();
8874
8875        if output.trim().is_empty() {
8876            out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
8877        } else {
8878            out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
8879            out.push_str(&output);
8880            out.push_str(
8881                "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
8882            );
8883        }
8884    }
8885
8886    #[cfg(not(target_os = "windows"))]
8887    {
8888        out.push_str("Checking dmesg for hardware errors...\n");
8889        let dmesg = Command::new("dmesg")
8890            .args(["--level=err,crit,alert"])
8891            .output()
8892            .ok()
8893            .and_then(|o| String::from_utf8(o.stdout).ok())
8894            .unwrap_or_default();
8895        if dmesg.is_empty() {
8896            out.push_str("  No critical hardware errors found in dmesg.\n");
8897        } else {
8898            out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
8899        }
8900    }
8901
8902    Ok(out.trim_end().to_string())
8903}
8904
8905fn inspect_drivers(max_entries: usize) -> Result<String, String> {
8906    let mut out = String::from("Host inspection: drivers\n\n");
8907
8908    #[cfg(target_os = "windows")]
8909    {
8910        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);
8911        let output = Command::new("powershell")
8912            .args(["-NoProfile", "-Command", &ps_cmd])
8913            .output()
8914            .ok()
8915            .and_then(|o| String::from_utf8(o.stdout).ok())
8916            .unwrap_or_default();
8917
8918        if output.trim().is_empty() {
8919            out.push_str("No drivers retrieved via WMI.\n");
8920        } else {
8921            out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
8922            out.push_str(&output);
8923        }
8924    }
8925
8926    #[cfg(not(target_os = "windows"))]
8927    {
8928        out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
8929        let lsmod = Command::new("lsmod")
8930            .output()
8931            .ok()
8932            .and_then(|o| String::from_utf8(o.stdout).ok())
8933            .unwrap_or_default();
8934        out.push_str(
8935            &lsmod
8936                .lines()
8937                .take(max_entries)
8938                .collect::<Vec<_>>()
8939                .join("\n"),
8940        );
8941    }
8942
8943    Ok(out.trim_end().to_string())
8944}
8945
8946fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
8947    let mut out = String::from("Host inspection: peripherals\n\n");
8948
8949    #[cfg(target_os = "windows")]
8950    {
8951        let _ = max_entries;
8952        out.push_str("=== USB Controllers & Hubs ===\n");
8953        let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \"  $($_.Name) ($($_.Status))\" }"])
8954            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8955        out.push_str(if usb.is_empty() {
8956            "  None detected.\n"
8957        } else {
8958            &usb
8959        });
8960
8961        out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
8962        let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \"  [KB] $($_.Name) ($($_.Status))\" }"])
8963            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8964        let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \"  [PTR] $($_.Name) ($($_.Status))\" }"])
8965            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8966        out.push_str(&kb);
8967        out.push_str(&mouse);
8968
8969        out.push_str("\n=== Connected Monitors (WMI) ===\n");
8970        let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \"  Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
8971            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8972        out.push_str(if mon.is_empty() {
8973            "  No active monitors identified via WMI.\n"
8974        } else {
8975            &mon
8976        });
8977    }
8978
8979    #[cfg(not(target_os = "windows"))]
8980    {
8981        out.push_str("=== Connected USB Devices (lsusb) ===\n");
8982        let lsusb = Command::new("lsusb")
8983            .output()
8984            .ok()
8985            .and_then(|o| String::from_utf8(o.stdout).ok())
8986            .unwrap_or_default();
8987        out.push_str(
8988            &lsusb
8989                .lines()
8990                .take(max_entries)
8991                .collect::<Vec<_>>()
8992                .join("\n"),
8993        );
8994    }
8995
8996    Ok(out.trim_end().to_string())
8997}
8998
8999fn inspect_sessions(max_entries: usize) -> Result<String, String> {
9000    let mut out = String::from("Host inspection: sessions\n\n");
9001
9002    #[cfg(target_os = "windows")]
9003    {
9004        let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
9005    "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
9006}"#;
9007        if let Ok(o) = Command::new("powershell")
9008            .args(["-NoProfile", "-Command", script])
9009            .output()
9010        {
9011            let text = String::from_utf8_lossy(&o.stdout);
9012            let lines: Vec<&str> = text.lines().collect();
9013            if lines.is_empty() {
9014                out.push_str("No active logon sessions enumerated via WMI.\n");
9015            } else {
9016                out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
9017                for line in lines
9018                    .iter()
9019                    .take(max_entries)
9020                    .filter(|l| !l.trim().is_empty())
9021                {
9022                    let parts: Vec<&str> = line.trim().split('|').collect();
9023                    if parts.len() == 4 {
9024                        let logon_type = match parts[2] {
9025                            "2" => "Interactive",
9026                            "3" => "Network",
9027                            "4" => "Batch",
9028                            "5" => "Service",
9029                            "7" => "Unlock",
9030                            "8" => "NetworkCleartext",
9031                            "9" => "NewCredentials",
9032                            "10" => "RemoteInteractive",
9033                            "11" => "CachedInteractive",
9034                            _ => "Other",
9035                        };
9036                        out.push_str(&format!(
9037                            "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
9038                            parts[0], logon_type, parts[1], parts[3]
9039                        ));
9040                    }
9041                }
9042            }
9043        }
9044    }
9045
9046    #[cfg(not(target_os = "windows"))]
9047    {
9048        out.push_str("=== Logged-in Users (who) ===\n");
9049        let who = Command::new("who")
9050            .output()
9051            .ok()
9052            .and_then(|o| String::from_utf8(o.stdout).ok())
9053            .unwrap_or_default();
9054        out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
9055    }
9056
9057    Ok(out.trim_end().to_string())
9058}
9059
9060async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
9061    let mut out = String::from("Host inspection: disk_benchmark\n\n");
9062    let mut final_path = path;
9063
9064    if !final_path.exists() {
9065        if let Ok(current_exe) = std::env::current_exe() {
9066            out.push_str(&format!(
9067                "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
9068                final_path.display()
9069            ));
9070            final_path = current_exe;
9071        } else {
9072            return Err(format!("Target not found: {}", final_path.display()));
9073        }
9074    }
9075
9076    let target = if final_path.is_dir() {
9077        // Find a representative file to read
9078        let mut target_file = final_path.join("Cargo.toml");
9079        if !target_file.exists() {
9080            target_file = final_path.join("README.md");
9081        }
9082        if !target_file.exists() {
9083            return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
9084        }
9085        target_file
9086    } else {
9087        final_path
9088    };
9089
9090    out.push_str(&format!("Target: {}\n", target.display()));
9091    out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
9092
9093    #[cfg(target_os = "windows")]
9094    {
9095        let script = format!(
9096            r#"
9097$target = "{}"
9098if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
9099
9100$diskQueue = @()
9101$readStats = @()
9102$startTime = Get-Date
9103$duration = 5
9104
9105# Background reader job
9106$job = Start-Job -ScriptBlock {{
9107    param($t, $d)
9108    $stop = (Get-Date).AddSeconds($d)
9109    while ((Get-Date) -lt $stop) {{
9110        try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
9111    }}
9112}} -ArgumentList $target, $duration
9113
9114# Metrics collector loop
9115$stopTime = (Get-Date).AddSeconds($duration)
9116while ((Get-Date) -lt $stopTime) {{
9117    $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
9118    if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
9119    
9120    $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
9121    if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
9122    
9123    Start-Sleep -Milliseconds 250
9124}}
9125
9126Stop-Job $job
9127Receive-Job $job | Out-Null
9128Remove-Job $job
9129
9130$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
9131$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
9132$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
9133
9134"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
9135"#,
9136            target.display()
9137        );
9138
9139        let output = Command::new("powershell")
9140            .args(["-NoProfile", "-Command", &script])
9141            .output()
9142            .map_err(|e| format!("Benchmark failed: {e}"))?;
9143
9144        let raw = String::from_utf8_lossy(&output.stdout);
9145        let text = raw.trim();
9146
9147        if text.starts_with("ERROR") {
9148            return Err(text.to_string());
9149        }
9150
9151        let mut lines = text.lines();
9152        if let Some(metrics_line) = lines.next() {
9153            let parts: Vec<&str> = metrics_line.split('|').collect();
9154            let mut avg_q = "unknown".to_string();
9155            let mut max_q = "unknown".to_string();
9156            let mut avg_r = "unknown".to_string();
9157
9158            for p in parts {
9159                if let Some((k, v)) = p.split_once(':') {
9160                    match k {
9161                        "AVG_Q" => avg_q = v.to_string(),
9162                        "MAX_Q" => max_q = v.to_string(),
9163                        "AVG_R" => avg_r = v.to_string(),
9164                        _ => {}
9165                    }
9166                }
9167            }
9168
9169            out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
9170            out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
9171            out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
9172            out.push_str(&format!("- Disk Throughput (Avg):  {} reads/sec\n", avg_r));
9173            out.push_str("\nVerdict: ");
9174            let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
9175            if q_num > 1.0 {
9176                out.push_str(
9177                    "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
9178                );
9179            } else if q_num > 0.1 {
9180                out.push_str("MODERATE LOAD — significant I/O pressure detected.");
9181            } else {
9182                out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
9183            }
9184        }
9185    }
9186
9187    #[cfg(not(target_os = "windows"))]
9188    {
9189        out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
9190        out.push_str("Generic disk load simulated.\n");
9191    }
9192
9193    Ok(out)
9194}
9195
9196fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
9197    let mut out = String::from("Host inspection: permissions\n\n");
9198    out.push_str(&format!(
9199        "Auditing access control for: {}\n\n",
9200        path.display()
9201    ));
9202
9203    #[cfg(target_os = "windows")]
9204    {
9205        let script = format!(
9206            "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
9207            path.display()
9208        );
9209        let output = Command::new("powershell")
9210            .args(["-NoProfile", "-Command", &script])
9211            .output()
9212            .map_err(|e| format!("ACL check failed: {e}"))?;
9213
9214        let text = String::from_utf8_lossy(&output.stdout);
9215        if text.trim().is_empty() {
9216            out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
9217        } else {
9218            out.push_str("=== Windows NTFS Permissions ===\n");
9219            out.push_str(&text);
9220        }
9221    }
9222
9223    #[cfg(not(target_os = "windows"))]
9224    {
9225        let output = Command::new("ls")
9226            .args(["-ld", &path.to_string_lossy()])
9227            .output()
9228            .map_err(|e| format!("ls check failed: {e}"))?;
9229        out.push_str("=== Unix File Permissions ===\n");
9230        out.push_str(&String::from_utf8_lossy(&output.stdout));
9231    }
9232
9233    Ok(out.trim_end().to_string())
9234}
9235
9236fn inspect_login_history(max_entries: usize) -> Result<String, String> {
9237    let mut out = String::from("Host inspection: login_history\n\n");
9238
9239    #[cfg(target_os = "windows")]
9240    {
9241        out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
9242        out.push_str("Note: This typically requires Administrator elevation.\n\n");
9243
9244        let n = max_entries.clamp(1, 50);
9245        let script = format!(
9246            r#"try {{
9247    $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
9248    $events | ForEach-Object {{
9249        $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
9250        # Extract target user name from the XML/Properties if possible
9251        $user = $_.Properties[5].Value
9252        $type = $_.Properties[8].Value
9253        "[$time] User: $user | Type: $type"
9254    }}
9255}} catch {{ "ERROR:" + $_.Exception.Message }}"#
9256        );
9257
9258        let output = Command::new("powershell")
9259            .args(["-NoProfile", "-Command", &script])
9260            .output()
9261            .map_err(|e| format!("Login history query failed: {e}"))?;
9262
9263        let text = String::from_utf8_lossy(&output.stdout);
9264        if text.starts_with("ERROR:") {
9265            out.push_str(&format!("Unable to query Security Log: {}\n", text));
9266        } else if text.trim().is_empty() {
9267            out.push_str("No recent logon events found or access denied.\n");
9268        } else {
9269            out.push_str("=== Recent Logons (Event ID 4624) ===\n");
9270            out.push_str(&text);
9271        }
9272    }
9273
9274    #[cfg(not(target_os = "windows"))]
9275    {
9276        let output = Command::new("last")
9277            .args(["-n", &max_entries.to_string()])
9278            .output()
9279            .map_err(|e| format!("last command failed: {e}"))?;
9280        out.push_str("=== Unix Login History (last) ===\n");
9281        out.push_str(&String::from_utf8_lossy(&output.stdout));
9282    }
9283
9284    Ok(out.trim_end().to_string())
9285}
9286
9287fn inspect_share_access(path: PathBuf) -> Result<String, String> {
9288    let mut out = String::from("Host inspection: share_access\n\n");
9289    out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
9290
9291    #[cfg(target_os = "windows")]
9292    {
9293        let script = format!(
9294            r#"
9295$p = '{}'
9296$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
9297if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
9298    $res.Reachable = $true
9299    try {{
9300        $null = Get-ChildItem -Path $p -ErrorAction Stop
9301        $res.Readable = $true
9302    }} catch {{
9303        $res.Error = $_.Exception.Message
9304    }}
9305}} else {{
9306    $res.Error = "Server unreachable (Ping failed)"
9307}}
9308"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
9309            path.display()
9310        );
9311
9312        let output = Command::new("powershell")
9313            .args(["-NoProfile", "-Command", &script])
9314            .output()
9315            .map_err(|e| format!("Share test failed: {e}"))?;
9316
9317        let text = String::from_utf8_lossy(&output.stdout);
9318        out.push_str("=== Share Triage Results ===\n");
9319        out.push_str(&text);
9320    }
9321
9322    #[cfg(not(target_os = "windows"))]
9323    {
9324        out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
9325    }
9326
9327    Ok(out.trim_end().to_string())
9328}
9329
9330fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
9331    let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
9332    out.push_str(&format!("Issue: {}\n\n", issue));
9333    out.push_str("Proposed Remediation Steps:\n");
9334    out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
9335    out.push_str("   `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
9336    out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
9337    out.push_str("   `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
9338    out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
9339    out.push_str("   `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
9340    out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
9341    out.push_str(
9342        "   `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n",
9343    );
9344
9345    Ok(out)
9346}
9347
9348fn inspect_registry_audit() -> Result<String, String> {
9349    let mut out = String::from("Host inspection: registry_audit\n\n");
9350    out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
9351
9352    #[cfg(target_os = "windows")]
9353    {
9354        let script = r#"
9355$findings = @()
9356
9357# 1. Image File Execution Options (Debugger Hijacking)
9358$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
9359if (Test-Path $ifeo) {
9360    Get-ChildItem $ifeo | ForEach-Object {
9361        $p = Get-ItemProperty $_.PSPath
9362        if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
9363    }
9364}
9365
9366# 2. Winlogon Shell Integrity
9367$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
9368$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
9369if ($shell -and $shell -ne "explorer.exe") {
9370    $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
9371}
9372
9373# 3. Session Manager BootExecute
9374$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
9375$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
9376if ($boot -and $boot -notcontains "autocheck autochk *") {
9377    $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
9378}
9379
9380if ($findings.Count -eq 0) {
9381    "PASS: No common registry hijacking or shell overrides detected."
9382} else {
9383    $findings -join "`n"
9384}
9385"#;
9386        let output = Command::new("powershell")
9387            .args(["-NoProfile", "-Command", &script])
9388            .output()
9389            .map_err(|e| format!("Registry audit failed: {e}"))?;
9390
9391        let text = String::from_utf8_lossy(&output.stdout);
9392        out.push_str("=== Persistence & Integrity Check ===\n");
9393        out.push_str(&text);
9394    }
9395
9396    #[cfg(not(target_os = "windows"))]
9397    {
9398        out.push_str("Registry auditing is specific to Windows environments.\n");
9399    }
9400
9401    Ok(out.trim_end().to_string())
9402}
9403
9404fn inspect_thermal() -> Result<String, String> {
9405    let mut out = String::from("Host inspection: thermal\n\n");
9406    out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
9407
9408    #[cfg(target_os = "windows")]
9409    {
9410        let script = r#"
9411$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
9412if ($thermal) {
9413    $thermal | ForEach-Object {
9414        $temp = [math]::Round(($_.Temperature - 273.15), 1)
9415        "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
9416    }
9417} else {
9418    "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
9419    $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
9420    "Current CPU Load: $throttling%"
9421}
9422"#;
9423        let output = Command::new("powershell")
9424            .args(["-NoProfile", "-Command", script])
9425            .output()
9426            .map_err(|e| format!("Thermal check failed: {e}"))?;
9427        out.push_str("=== Windows Thermal State ===\n");
9428        out.push_str(&String::from_utf8_lossy(&output.stdout));
9429    }
9430
9431    #[cfg(not(target_os = "windows"))]
9432    {
9433        out.push_str(
9434            "Thermal inspection is currently optimized for Windows performance counters.\n",
9435        );
9436    }
9437
9438    Ok(out.trim_end().to_string())
9439}
9440
9441fn inspect_activation() -> Result<String, String> {
9442    let mut out = String::from("Host inspection: activation\n\n");
9443    out.push_str("Auditing Windows activation and license state...\n\n");
9444
9445    #[cfg(target_os = "windows")]
9446    {
9447        let script = r#"
9448$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
9449$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
9450"Status: $($xpr.Trim())"
9451"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
9452"#;
9453        let output = Command::new("powershell")
9454            .args(["-NoProfile", "-Command", script])
9455            .output()
9456            .map_err(|e| format!("Activation check failed: {e}"))?;
9457        out.push_str("=== Windows License Report ===\n");
9458        out.push_str(&String::from_utf8_lossy(&output.stdout));
9459    }
9460
9461    #[cfg(not(target_os = "windows"))]
9462    {
9463        out.push_str("Windows activation check is specific to the Windows platform.\n");
9464    }
9465
9466    Ok(out.trim_end().to_string())
9467}
9468
9469fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
9470    let mut out = String::from("Host inspection: patch_history\n\n");
9471    out.push_str(&format!(
9472        "Listing the last {} installed Windows updates (KBs)...\n\n",
9473        max_entries
9474    ));
9475
9476    #[cfg(target_os = "windows")]
9477    {
9478        let n = max_entries.clamp(1, 50);
9479        let script = format!(
9480            "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
9481            n
9482        );
9483        let output = Command::new("powershell")
9484            .args(["-NoProfile", "-Command", &script])
9485            .output()
9486            .map_err(|e| format!("Patch history query failed: {e}"))?;
9487        out.push_str("=== Recent HotFixes (KBs) ===\n");
9488        out.push_str(&String::from_utf8_lossy(&output.stdout));
9489    }
9490
9491    #[cfg(not(target_os = "windows"))]
9492    {
9493        out.push_str("Patch history is currently focused on Windows HotFixes.\n");
9494    }
9495
9496    Ok(out.trim_end().to_string())
9497}
9498
9499// ── ad_user ──────────────────────────────────────────────────────────────────
9500
9501fn inspect_ad_user(identity: &str) -> Result<String, String> {
9502    let mut out = String::from("Host inspection: ad_user\n\n");
9503    let ident = identity.trim();
9504    if ident.is_empty() {
9505        out.push_str("Status: No identity specified. Performing self-discovery...\n");
9506        #[cfg(target_os = "windows")]
9507        {
9508            let script = r#"
9509$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
9510"USER: " + $u.Name
9511"SID: " + $u.User.Value
9512"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
9513"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
9514"#;
9515            let output = Command::new("powershell")
9516                .args(["-NoProfile", "-Command", script])
9517                .output()
9518                .ok();
9519            if let Some(o) = output {
9520                out.push_str(&String::from_utf8_lossy(&o.stdout));
9521            }
9522        }
9523        return Ok(out);
9524    }
9525
9526    #[cfg(target_os = "windows")]
9527    {
9528        let script = format!(
9529            r#"
9530try {{
9531    $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
9532    "NAME: " + $u.Name
9533    "SID: " + $u.SID
9534    "ENABLED: " + $u.Enabled
9535    "EXPIRED: " + $u.PasswordExpired
9536    "LOGON: " + $u.LastLogonDate
9537    "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
9538}} catch {{
9539    # Fallback to net user if AD module is missing or fails
9540    $net = net user "{ident}" /domain 2>&1
9541    if ($LASTEXITCODE -eq 0) {{
9542        $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
9543    }} else {{
9544        "ERROR: " + $_.Exception.Message
9545    }}
9546}}"#
9547        );
9548
9549        let output = Command::new("powershell")
9550            .args(["-NoProfile", "-Command", &script])
9551            .output()
9552            .ok();
9553
9554        if let Some(o) = output {
9555            let stdout = String::from_utf8_lossy(&o.stdout);
9556            if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
9557                out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
9558            }
9559            out.push_str(&stdout);
9560        }
9561    }
9562
9563    #[cfg(not(target_os = "windows"))]
9564    {
9565        let _ = ident;
9566        out.push_str("(AD User lookup only available on Windows nodes)\n");
9567    }
9568
9569    Ok(out.trim_end().to_string())
9570}
9571
9572// ── dns_lookup ───────────────────────────────────────────────────────────────
9573
9574fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
9575    let mut out = String::from("Host inspection: dns_lookup\n\n");
9576    let target = name.trim();
9577    if target.is_empty() {
9578        return Err("Missing required target name for dns_lookup.".to_string());
9579    }
9580
9581    #[cfg(target_os = "windows")]
9582    {
9583        let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
9584        let output = Command::new("powershell")
9585            .args(["-NoProfile", "-Command", &script])
9586            .output()
9587            .ok();
9588        if let Some(o) = output {
9589            let stdout = String::from_utf8_lossy(&o.stdout);
9590            if stdout.trim().is_empty() {
9591                out.push_str(&format!("No {record_type} records found for {target}.\n"));
9592            } else {
9593                out.push_str(&stdout);
9594            }
9595        }
9596    }
9597
9598    #[cfg(not(target_os = "windows"))]
9599    {
9600        let output = Command::new("dig")
9601            .args([target, record_type, "+short"])
9602            .output()
9603            .ok();
9604        if let Some(o) = output {
9605            out.push_str(&String::from_utf8_lossy(&o.stdout));
9606        }
9607    }
9608
9609    Ok(out.trim_end().to_string())
9610}
9611
9612// ── hyperv ───────────────────────────────────────────────────────────────────
9613
9614fn inspect_hyperv() -> Result<String, String> {
9615    let mut out = String::from("Host inspection: hyperv\n\n");
9616
9617    #[cfg(target_os = "windows")]
9618    {
9619        let script = "Get-VM -ErrorAction SilentlyContinue | Select-Object Name, State, Uptime, Status, CPUUsage, MemoryAssigned | Format-Table -AutoSize";
9620        let output = Command::new("powershell")
9621            .args(["-NoProfile", "-Command", script])
9622            .output()
9623            .ok();
9624        if let Some(o) = output {
9625            let stdout = String::from_utf8_lossy(&o.stdout);
9626            if stdout.trim().is_empty() {
9627                out.push_str(
9628                    "No Hyper-V Virtual Machines found or Hyper-V module not installed.\n",
9629                );
9630            } else {
9631                out.push_str(&stdout);
9632            }
9633        }
9634    }
9635
9636    #[cfg(not(target_os = "windows"))]
9637    {
9638        out.push_str("(Hyper-V lookup only available on Windows hosts)\n");
9639    }
9640
9641    Ok(out.trim_end().to_string())
9642}
9643
9644// ── ip_config ────────────────────────────────────────────────────────────────
9645
9646fn inspect_ip_config() -> Result<String, String> {
9647    let mut out = String::from("Host inspection: ip_config\n\n");
9648
9649    #[cfg(target_os = "windows")]
9650    {
9651        let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
9652            $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
9653            '\\n  Status: ' + $_.NetAdapter.Status + \
9654            '\\n  Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
9655            '\\n  DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
9656            '\\n  DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
9657            '\\n  IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
9658            '\\n  DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
9659        }";
9660        let output = Command::new("powershell")
9661            .args(["-NoProfile", "-Command", script])
9662            .output()
9663            .ok();
9664        if let Some(o) = output {
9665            out.push_str(&String::from_utf8_lossy(&o.stdout));
9666        }
9667    }
9668
9669    #[cfg(not(target_os = "windows"))]
9670    {
9671        let output = Command::new("ip").args(["addr", "show"]).output().ok();
9672        if let Some(o) = output {
9673            out.push_str(&String::from_utf8_lossy(&o.stdout));
9674        }
9675    }
9676
9677    Ok(out.trim_end().to_string())
9678}
9679
9680async fn inspect_overclocker() -> Result<String, String> {
9681    let mut out = String::from("Host inspection: overclocker\n\n");
9682
9683    #[cfg(target_os = "windows")]
9684    {
9685        out.push_str(
9686            "Gathering real-time silicon telemetry (2-second high-fidelity average)...\n\n",
9687        );
9688
9689        // 1. NVIDIA Census
9690        let nvidia = Command::new("nvidia-smi")
9691            .args([
9692                "--query-gpu=name,clocks.current.graphics,clocks.current.memory,fan.speed,power.draw,temperature.gpu,clocks_throttle_reasons.active",
9693                "--format=csv,noheader,nounits",
9694            ])
9695            .output();
9696
9697        if let Ok(o) = nvidia {
9698            let stdout = String::from_utf8_lossy(&o.stdout);
9699            if !stdout.trim().is_empty() {
9700                out.push_str("=== GPU SENSE (NVIDIA) ===\n");
9701                let parts: Vec<&str> = stdout.trim().split(',').map(|s| s.trim()).collect();
9702                if parts.len() >= 6 {
9703                    out.push_str(&format!("- Model:      {}\n", parts[0]));
9704                    out.push_str(&format!("- Graphics:   {} MHz\n", parts[1]));
9705                    out.push_str(&format!("- Memory:     {} MHz\n", parts[2]));
9706                    out.push_str(&format!("- Fan Speed:  {}%\n", parts[3]));
9707                    out.push_str(&format!("- Power Draw: {} W\n", parts[4]));
9708                    out.push_str(&format!("- Temperature: {}°C\n", parts[5]));
9709
9710                    if parts.len() > 6 {
9711                        let throttle_hex = parts[6];
9712                        let reasons = decode_nvidia_throttle_reasons(throttle_hex);
9713                        if !reasons.is_empty() {
9714                            out.push_str(&format!("- Throttling:  YES [Reason: {}]\n", reasons));
9715                        } else {
9716                            out.push_str("- Throttling:  None (Performance State: Max)\n");
9717                        }
9718                    }
9719                }
9720                out.push_str("\n");
9721            }
9722        }
9723
9724        // 1b. Session Trends (RAM-only historians)
9725        let gpu_state = &crate::ui::gpu_monitor::GLOBAL_GPU_STATE;
9726        let history = gpu_state.history.lock().unwrap();
9727        if history.len() >= 2 {
9728            out.push_str("=== SILICON TRENDS (Session) ===\n");
9729            let first = history.front().unwrap();
9730            let last = history.back().unwrap();
9731
9732            let temp_diff = last.temperature as i32 - first.temperature as i32;
9733            let clock_diff = last.core_clock as i32 - first.core_clock as i32;
9734
9735            let temp_trend = if temp_diff > 1 {
9736                "Rising"
9737            } else if temp_diff < -1 {
9738                "Falling"
9739            } else {
9740                "Stable"
9741            };
9742            let clock_trend = if clock_diff > 10 {
9743                "Increasing"
9744            } else if clock_diff < -10 {
9745                "Decreasing"
9746            } else {
9747                "Stable"
9748            };
9749
9750            out.push_str(&format!(
9751                "- Temperature: {} ({}°C anomaly)\n",
9752                temp_trend, temp_diff
9753            ));
9754            out.push_str(&format!(
9755                "- Core Clock:  {} ({} MHz delta)\n",
9756                clock_trend, clock_diff
9757            ));
9758            out.push_str("\n");
9759        }
9760
9761        // 2. CPU Time-Series (2 samples)
9762        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))\" }";
9763        let cpu_stats = Command::new("powershell")
9764            .args(["-NoProfile", "-Command", ps_cmd])
9765            .output();
9766
9767        if let Ok(o) = cpu_stats {
9768            let stdout = String::from_utf8_lossy(&o.stdout);
9769            if !stdout.trim().is_empty() {
9770                out.push_str("=== SILICON CORE (CPU) ===\n");
9771                for line in stdout.lines() {
9772                    if let Some((path, val)) = line.split_once(':') {
9773                        if path.to_lowercase().contains("processor frequency") {
9774                            out.push_str(&format!("- Current Freq:  {} MHz (2s Avg)\n", val));
9775                        } else if path.to_lowercase().contains("% of maximum frequency") {
9776                            out.push_str(&format!("- Throttling:     {}% of Max Capacity\n", val));
9777                            let throttle_num = val.parse::<f64>().unwrap_or(100.0);
9778                            if throttle_num < 95.0 {
9779                                out.push_str(
9780                                    "  [WARNING] Active downclocking or power-saving detected.\n",
9781                                );
9782                            }
9783                        }
9784                    }
9785                }
9786            }
9787        }
9788
9789        // 2b. CPU Thermal Fallback
9790        let thermal = Command::new("powershell")
9791            .args([
9792                "-NoProfile",
9793                "-Command",
9794                "Get-CimInstance -Namespace root\\wmi -ClassName MSAcpi_ThermalZoneTemperature | Select-Object @{N='Temp';E={($_.CurrentTemperature - 2732) / 10}} | ConvertTo-Json",
9795            ])
9796            .output();
9797        if let Ok(o) = thermal {
9798            let stdout = String::from_utf8_lossy(&o.stdout);
9799            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
9800                let temp = if v.is_array() {
9801                    v[0].get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
9802                } else {
9803                    v.get("Temp").and_then(|x| x.as_f64()).unwrap_or(0.0)
9804                };
9805                if temp > 1.0 {
9806                    out.push_str(&format!("- CPU Package:   {}°C (ACPI Zone)\n", temp));
9807                }
9808            }
9809        }
9810
9811        // 3. WMI Static Fallback/Context
9812        let wmi = Command::new("powershell")
9813            .args([
9814                "-NoProfile",
9815                "-Command",
9816                "Get-CimInstance Win32_Processor | Select-Object Name, MaxClockSpeed, CurrentVoltage | ConvertTo-Json",
9817            ])
9818            .output();
9819
9820        if let Ok(o) = wmi {
9821            let stdout = String::from_utf8_lossy(&o.stdout);
9822            if let Ok(v) = serde_json::from_str::<Value>(&stdout) {
9823                out.push_str("\n=== HARDWARE DNA ===\n");
9824                out.push_str(&format!(
9825                    "- Rated Max:     {} MHz\n",
9826                    v.get("MaxClockSpeed").and_then(|x| x.as_u64()).unwrap_or(0)
9827                ));
9828                out.push_str(&format!(
9829                    "- Voltage:       {} (Raw WMI)\n",
9830                    v.get("CurrentVoltage")
9831                        .and_then(|x| x.as_u64())
9832                        .unwrap_or(0)
9833                ));
9834            }
9835        }
9836    }
9837
9838    #[cfg(not(target_os = "windows"))]
9839    {
9840        out.push_str("Overclocker telemetry is currently optimized for Windows performance counters and NVIDIA drivers.\n");
9841    }
9842
9843    Ok(out.trim_end().to_string())
9844}
9845
9846/// Decodes the NVIDIA Clocks Throttle Reasons HEX bitmask.
9847fn decode_nvidia_throttle_reasons(hex: &str) -> String {
9848    let hex = hex.trim().trim_start_matches("0x");
9849    let val = match u64::from_str_radix(hex, 16) {
9850        Ok(v) => v,
9851        Err(_) => return String::new(),
9852    };
9853
9854    if val == 0 {
9855        return String::new();
9856    }
9857
9858    let mut reasons = Vec::new();
9859    if val & 0x01 != 0 {
9860        reasons.push("GPU Idle");
9861    }
9862    if val & 0x02 != 0 {
9863        reasons.push("Applications Clocks Setting");
9864    }
9865    if val & 0x04 != 0 {
9866        reasons.push("SW Power Cap (PL1/PL2)");
9867    }
9868    if val & 0x08 != 0 {
9869        reasons.push("HW Slowdown (Thermal/Power)");
9870    }
9871    if val & 0x10 != 0 {
9872        reasons.push("Sync Boost");
9873    }
9874    if val & 0x20 != 0 {
9875        reasons.push("SW Thermal Slowdown");
9876    }
9877    if val & 0x40 != 0 {
9878        reasons.push("HW Thermal Slowdown");
9879    }
9880    if val & 0x80 != 0 {
9881        reasons.push("HW Power Brake Slowdown");
9882    }
9883    if val & 0x100 != 0 {
9884        reasons.push("Display Clock Setting");
9885    }
9886
9887    reasons.join(", ")
9888}