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 topic = args
13        .get("topic")
14        .and_then(|v| v.as_str())
15        .unwrap_or("summary");
16    let max_entries = parse_max_entries(args);
17
18    match topic {
19        "summary" => inspect_summary(max_entries),
20        "toolchains" => inspect_toolchains(),
21        "path" => inspect_path(max_entries),
22        "env_doctor" => inspect_env_doctor(max_entries),
23        "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
24        "network" => inspect_network(max_entries),
25        "services" => inspect_services(parse_name_filter(args), max_entries),
26        "processes" => inspect_processes(parse_name_filter(args), max_entries),
27        "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
28        "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
29        "disk" => {
30            let path = resolve_optional_path(args)?;
31            inspect_disk(path, max_entries).await
32        }
33        "ports" => inspect_ports(parse_port_filter(args), max_entries),
34        "log_check" => inspect_log_check(max_entries),
35        "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
36        "health_report" | "system_health" => inspect_health_report(),
37        "storage" => inspect_storage(max_entries),
38        "hardware" => inspect_hardware(),
39        "updates" | "windows_update" => inspect_updates(),
40        "security" | "antivirus" | "defender" => inspect_security(),
41        "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
42        "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
43        "battery" => inspect_battery(),
44        "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
45        "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
46        "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
47        "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
48        "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
49        "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
50        "vpn" => inspect_vpn(),
51        "proxy" | "proxy_settings" => inspect_proxy(),
52        "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
53        "traceroute" | "tracert" | "trace_route" | "trace" => {
54            let host = args
55                .get("host")
56                .and_then(|v| v.as_str())
57                .unwrap_or("8.8.8.8")
58                .to_string();
59            inspect_traceroute(&host, max_entries)
60        }
61        "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
62        "arp" | "arp_table" => inspect_arp(),
63        "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
64        "os_config" | "system_config" => inspect_os_config(),
65        "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
66        "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
67        "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
68        "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
69        "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
70        "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
71        "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
72        "git_config" | "git_global" => inspect_git_config(),
73        "databases" | "database" | "db_services" | "db" => inspect_databases(),
74        "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
75        "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
76        "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
77        "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
78        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
79        "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
80        "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
81        "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
82        "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
83        "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
84        "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
85        "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
86        "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
87        "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
88        "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
89        "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
90        "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
91        "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
92        "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
93        "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
94        "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
95        "repo_doctor" => {
96            let path = resolve_optional_path(args)?;
97            inspect_repo_doctor(path, max_entries)
98        }
99        "directory" => {
100            let raw_path = args
101                .get("path")
102                .and_then(|v| v.as_str())
103                .ok_or_else(|| {
104                    "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
105                        .to_string()
106                })?;
107            let resolved = resolve_path(raw_path)?;
108            inspect_directory("Directory", resolved, max_entries).await
109        }
110        "disk_benchmark" | "stress_test" | "io_intensity" => {
111            let path = resolve_optional_path(args)?;
112            inspect_disk_benchmark(path).await
113        }
114        other => Err(format!(
115            "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.",
116            other
117        )),
118
119    }
120}
121
122fn parse_max_entries(args: &Value) -> usize {
123    args.get("max_entries")
124        .and_then(|v| v.as_u64())
125        .map(|n| n as usize)
126        .unwrap_or(DEFAULT_MAX_ENTRIES)
127        .clamp(1, MAX_ENTRIES_CAP)
128}
129
130fn parse_port_filter(args: &Value) -> Option<u16> {
131    args.get("port")
132        .and_then(|v| v.as_u64())
133        .and_then(|n| u16::try_from(n).ok())
134}
135
136fn parse_name_filter(args: &Value) -> Option<String> {
137    args.get("name")
138        .and_then(|v| v.as_str())
139        .map(str::trim)
140        .filter(|value| !value.is_empty())
141        .map(|value| value.to_string())
142}
143
144fn parse_issue_text(args: &Value) -> Option<String> {
145    args.get("issue")
146        .and_then(|v| v.as_str())
147        .map(str::trim)
148        .filter(|value| !value.is_empty())
149        .map(|value| value.to_string())
150}
151
152fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
153    match args.get("path").and_then(|v| v.as_str()) {
154        Some(raw_path) => resolve_path(raw_path),
155        None => {
156            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
157        }
158    }
159}
160
161fn inspect_summary(max_entries: usize) -> Result<String, String> {
162    let current_dir =
163        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
164    let workspace_root = crate::tools::file_ops::workspace_root();
165    let workspace_mode = workspace_mode_label(&workspace_root);
166    let path_stats = analyze_path_env();
167    let toolchains = collect_toolchains();
168
169    let mut out = String::from("Host inspection: summary\n\n");
170    out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
171    out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
172    out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
173    out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
174    out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
175    out.push_str(&format!(
176        "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
177        path_stats.total_entries,
178        path_stats.unique_entries,
179        path_stats.duplicate_entries.len(),
180        path_stats.missing_entries.len()
181    ));
182
183    if toolchains.found.is_empty() {
184        out.push_str(
185            "- Toolchains found: none of the common developer tools were detected on PATH\n",
186        );
187    } else {
188        out.push_str("- Toolchains found:\n");
189        for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
190            out.push_str(&format!("  - {}: {}\n", label, version));
191        }
192        if toolchains.found.len() > max_entries.min(8) {
193            out.push_str(&format!(
194                "  - ... {} more found tools omitted\n",
195                toolchains.found.len() - max_entries.min(8)
196            ));
197        }
198    }
199
200    if !toolchains.missing.is_empty() {
201        out.push_str(&format!(
202            "- Common tools not detected on PATH: {}\n",
203            toolchains.missing.join(", ")
204        ));
205    }
206
207    for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
208        match path {
209            Some(path) if path.exists() => match count_top_level_items(&path) {
210                Ok(count) => out.push_str(&format!(
211                    "- {}: {} top-level items at {}\n",
212                    label,
213                    count,
214                    path.display()
215                )),
216                Err(e) => out.push_str(&format!(
217                    "- {}: exists at {} but could not inspect ({})\n",
218                    label,
219                    path.display(),
220                    e
221                )),
222            },
223            Some(path) => out.push_str(&format!(
224                "- {}: expected at {} but not found\n",
225                label,
226                path.display()
227            )),
228            None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
229        }
230    }
231
232    Ok(out.trim_end().to_string())
233}
234
235fn inspect_toolchains() -> Result<String, String> {
236    let report = collect_toolchains();
237    let mut out = String::from("Host inspection: toolchains\n\n");
238
239    if report.found.is_empty() {
240        out.push_str("- No common developer tools were detected on PATH.");
241    } else {
242        out.push_str("Detected developer tools:\n");
243        for (label, version) in report.found {
244            out.push_str(&format!("- {}: {}\n", label, version));
245        }
246    }
247
248    if !report.missing.is_empty() {
249        out.push_str("\nNot detected on PATH:\n");
250        for label in report.missing {
251            out.push_str(&format!("- {}\n", label));
252        }
253    }
254
255    Ok(out.trim_end().to_string())
256}
257
258fn inspect_path(max_entries: usize) -> Result<String, String> {
259    let path_stats = analyze_path_env();
260    let mut out = String::from("Host inspection: PATH\n\n");
261    out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
262    out.push_str(&format!(
263        "- Unique entries: {}\n",
264        path_stats.unique_entries
265    ));
266    out.push_str(&format!(
267        "- Duplicate entries: {}\n",
268        path_stats.duplicate_entries.len()
269    ));
270    out.push_str(&format!(
271        "- Missing paths: {}\n",
272        path_stats.missing_entries.len()
273    ));
274
275    out.push_str("\nPATH entries:\n");
276    for entry in path_stats.entries.iter().take(max_entries) {
277        out.push_str(&format!("- {}\n", entry));
278    }
279    if path_stats.entries.len() > max_entries {
280        out.push_str(&format!(
281            "- ... {} more entries omitted\n",
282            path_stats.entries.len() - max_entries
283        ));
284    }
285
286    if !path_stats.duplicate_entries.is_empty() {
287        out.push_str("\nDuplicate entries:\n");
288        for entry in path_stats.duplicate_entries.iter().take(max_entries) {
289            out.push_str(&format!("- {}\n", entry));
290        }
291        if path_stats.duplicate_entries.len() > max_entries {
292            out.push_str(&format!(
293                "- ... {} more duplicates omitted\n",
294                path_stats.duplicate_entries.len() - max_entries
295            ));
296        }
297    }
298
299    if !path_stats.missing_entries.is_empty() {
300        out.push_str("\nMissing directories:\n");
301        for entry in path_stats.missing_entries.iter().take(max_entries) {
302            out.push_str(&format!("- {}\n", entry));
303        }
304        if path_stats.missing_entries.len() > max_entries {
305            out.push_str(&format!(
306                "- ... {} more missing entries omitted\n",
307                path_stats.missing_entries.len() - max_entries
308            ));
309        }
310    }
311
312    Ok(out.trim_end().to_string())
313}
314
315fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
316    let path_stats = analyze_path_env();
317    let toolchains = collect_toolchains();
318    let package_managers = collect_package_managers();
319    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
320
321    let mut out = String::from("Host inspection: env_doctor\n\n");
322    out.push_str(&format!(
323        "- PATH health: {} duplicates, {} missing entries\n",
324        path_stats.duplicate_entries.len(),
325        path_stats.missing_entries.len()
326    ));
327    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
328    out.push_str(&format!(
329        "- Package managers found: {}\n",
330        package_managers.found.len()
331    ));
332
333    if !package_managers.found.is_empty() {
334        out.push_str("\nPackage managers:\n");
335        for (label, version) in package_managers.found.iter().take(max_entries) {
336            out.push_str(&format!("- {}: {}\n", label, version));
337        }
338        if package_managers.found.len() > max_entries {
339            out.push_str(&format!(
340                "- ... {} more package managers omitted\n",
341                package_managers.found.len() - max_entries
342            ));
343        }
344    }
345
346    if !path_stats.duplicate_entries.is_empty() {
347        out.push_str("\nDuplicate PATH entries:\n");
348        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
349            out.push_str(&format!("- {}\n", entry));
350        }
351        if path_stats.duplicate_entries.len() > max_entries.min(5) {
352            out.push_str(&format!(
353                "- ... {} more duplicate entries omitted\n",
354                path_stats.duplicate_entries.len() - max_entries.min(5)
355            ));
356        }
357    }
358
359    if !path_stats.missing_entries.is_empty() {
360        out.push_str("\nMissing PATH entries:\n");
361        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
362            out.push_str(&format!("- {}\n", entry));
363        }
364        if path_stats.missing_entries.len() > max_entries.min(5) {
365            out.push_str(&format!(
366                "- ... {} more missing entries omitted\n",
367                path_stats.missing_entries.len() - max_entries.min(5)
368            ));
369        }
370    }
371
372    if !findings.is_empty() {
373        out.push_str("\nFindings:\n");
374        for finding in findings.iter().take(max_entries.max(5)) {
375            out.push_str(&format!("- {}\n", finding));
376        }
377        if findings.len() > max_entries.max(5) {
378            out.push_str(&format!(
379                "- ... {} more findings omitted\n",
380                findings.len() - max_entries.max(5)
381            ));
382        }
383    } else {
384        out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
385    }
386
387    out.push_str(
388        "\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.",
389    );
390
391    Ok(out.trim_end().to_string())
392}
393
394#[derive(Clone, Copy, Debug, Eq, PartialEq)]
395enum FixPlanKind {
396    EnvPath,
397    PortConflict,
398    LmStudio,
399    DriverInstall,
400    GroupPolicy,
401    FirewallRule,
402    SshKey,
403    WslSetup,
404    ServiceConfig,
405    WindowsActivation,
406    RegistryEdit,
407    ScheduledTaskCreate,
408    DiskCleanup,
409    Generic,
410}
411
412async fn inspect_fix_plan(
413    issue: Option<String>,
414    port_filter: Option<u16>,
415    max_entries: usize,
416) -> Result<String, String> {
417    let issue = issue.unwrap_or_else(|| {
418        "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
419            .to_string()
420    });
421    let plan_kind = classify_fix_plan_kind(&issue, port_filter);
422    match plan_kind {
423        FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
424        FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
425        FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
426        FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
427        FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
428        FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
429        FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
430        FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
431        FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
432        FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
433        FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
434        FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
435        FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
436        FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
437    }
438}
439
440fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
441    let lower = issue.to_ascii_lowercase();
442    // FirewallRule must be checked before PortConflict — "open port 80 in the firewall"
443    // is firewall rule creation, not a port ownership conflict.
444    if lower.contains("firewall rule")
445        || lower.contains("inbound rule")
446        || lower.contains("outbound rule")
447        || (lower.contains("firewall")
448            && (lower.contains("allow")
449                || lower.contains("block")
450                || lower.contains("create")
451                || lower.contains("open")))
452    {
453        FixPlanKind::FirewallRule
454    } else if port_filter.is_some()
455        || lower.contains("port ")
456        || lower.contains("address already in use")
457        || lower.contains("already in use")
458        || lower.contains("what owns port")
459        || lower.contains("listening on port")
460    {
461        FixPlanKind::PortConflict
462    } else if lower.contains("lm studio")
463        || lower.contains("localhost:1234")
464        || lower.contains("/v1/models")
465        || lower.contains("no coding model loaded")
466        || lower.contains("embedding model")
467        || lower.contains("server on port 1234")
468        || lower.contains("runtime refresh")
469    {
470        FixPlanKind::LmStudio
471    } else if lower.contains("driver")
472        || lower.contains("gpu driver")
473        || lower.contains("nvidia driver")
474        || lower.contains("amd driver")
475        || lower.contains("install driver")
476        || lower.contains("update driver")
477    {
478        FixPlanKind::DriverInstall
479    } else if lower.contains("group policy")
480        || lower.contains("gpedit")
481        || lower.contains("local policy")
482        || lower.contains("secpol")
483        || lower.contains("administrative template")
484    {
485        FixPlanKind::GroupPolicy
486    } else if lower.contains("ssh key")
487        || lower.contains("ssh-keygen")
488        || lower.contains("generate ssh")
489        || lower.contains("authorized_keys")
490        || lower.contains("id_rsa")
491        || lower.contains("id_ed25519")
492    {
493        FixPlanKind::SshKey
494    } else if lower.contains("wsl")
495        || lower.contains("windows subsystem for linux")
496        || lower.contains("install ubuntu")
497        || lower.contains("install linux on windows")
498        || lower.contains("wsl2")
499    {
500        FixPlanKind::WslSetup
501    } else if lower.contains("service")
502        && (lower.contains("start ")
503            || lower.contains("stop ")
504            || lower.contains("restart ")
505            || lower.contains("enable ")
506            || lower.contains("disable ")
507            || lower.contains("configure service"))
508    {
509        FixPlanKind::ServiceConfig
510    } else if lower.contains("activate windows")
511        || lower.contains("windows activation")
512        || lower.contains("product key")
513        || lower.contains("kms")
514        || lower.contains("not activated")
515    {
516        FixPlanKind::WindowsActivation
517    } else if lower.contains("registry")
518        || lower.contains("regedit")
519        || lower.contains("hklm")
520        || lower.contains("hkcu")
521        || lower.contains("reg add")
522        || lower.contains("reg delete")
523        || lower.contains("registry key")
524    {
525        FixPlanKind::RegistryEdit
526    } else if lower.contains("scheduled task")
527        || lower.contains("task scheduler")
528        || lower.contains("schtasks")
529        || lower.contains("create task")
530        || lower.contains("run on startup")
531        || lower.contains("run on schedule")
532        || lower.contains("cron")
533    {
534        FixPlanKind::ScheduledTaskCreate
535    } else if lower.contains("disk cleanup")
536        || lower.contains("free up disk")
537        || lower.contains("free up space")
538        || lower.contains("clear cache")
539        || lower.contains("disk full")
540        || lower.contains("low disk space")
541        || lower.contains("reclaim space")
542    {
543        FixPlanKind::DiskCleanup
544    } else if lower.contains("cargo")
545        || lower.contains("rustc")
546        || lower.contains("path")
547        || lower.contains("package manager")
548        || lower.contains("package managers")
549        || lower.contains("toolchain")
550        || lower.contains("winget")
551        || lower.contains("choco")
552        || lower.contains("scoop")
553        || lower.contains("python")
554        || lower.contains("node")
555    {
556        FixPlanKind::EnvPath
557    } else {
558        FixPlanKind::Generic
559    }
560}
561
562fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
563    let path_stats = analyze_path_env();
564    let toolchains = collect_toolchains();
565    let package_managers = collect_package_managers();
566    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
567    let found_tools = toolchains
568        .found
569        .iter()
570        .map(|(label, _)| label.as_str())
571        .collect::<HashSet<_>>();
572    let found_managers = package_managers
573        .found
574        .iter()
575        .map(|(label, _)| label.as_str())
576        .collect::<HashSet<_>>();
577
578    let mut out = String::from("Host inspection: fix_plan\n\n");
579    out.push_str(&format!("- Requested issue: {}\n", issue));
580    out.push_str("- Fix-plan type: environment/path\n");
581    out.push_str(&format!(
582        "- PATH health: {} duplicates, {} missing entries\n",
583        path_stats.duplicate_entries.len(),
584        path_stats.missing_entries.len()
585    ));
586    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
587    out.push_str(&format!(
588        "- Package managers found: {}\n",
589        package_managers.found.len()
590    ));
591
592    out.push_str("\nLikely causes:\n");
593    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
594        out.push_str(
595            "- 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",
596        );
597    }
598    if path_stats.duplicate_entries.is_empty()
599        && path_stats.missing_entries.is_empty()
600        && !findings.is_empty()
601    {
602        for finding in findings.iter().take(max_entries.max(4)) {
603            out.push_str(&format!("- {}\n", finding));
604        }
605    } else {
606        if !path_stats.duplicate_entries.is_empty() {
607            out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
608        }
609        if !path_stats.missing_entries.is_empty() {
610            out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
611        }
612    }
613    if found_tools.contains("node")
614        && !found_managers.contains("npm")
615        && !found_managers.contains("pnpm")
616    {
617        out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
618    }
619    if found_tools.contains("python")
620        && !found_managers.contains("pip")
621        && !found_managers.contains("uv")
622        && !found_managers.contains("pipx")
623    {
624        out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
625    }
626
627    out.push_str("\nFix plan:\n");
628    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");
629    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
630        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");
631    } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
632        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");
633    }
634    if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
635        out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
636    }
637    if found_tools.contains("node")
638        && !found_managers.contains("npm")
639        && !found_managers.contains("pnpm")
640    {
641        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");
642    }
643    if found_tools.contains("python")
644        && !found_managers.contains("pip")
645        && !found_managers.contains("uv")
646        && !found_managers.contains("pipx")
647    {
648        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");
649    }
650
651    if !path_stats.duplicate_entries.is_empty() {
652        out.push_str("\nExample duplicate PATH rows:\n");
653        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
654            out.push_str(&format!("- {}\n", entry));
655        }
656    }
657    if !path_stats.missing_entries.is_empty() {
658        out.push_str("\nExample missing PATH rows:\n");
659        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
660            out.push_str(&format!("- {}\n", entry));
661        }
662    }
663
664    out.push_str(
665        "\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.",
666    );
667    Ok(out.trim_end().to_string())
668}
669
670fn inspect_port_fix_plan(
671    issue: &str,
672    port_filter: Option<u16>,
673    max_entries: usize,
674) -> Result<String, String> {
675    let requested_port = port_filter.or_else(|| first_port_in_text(issue));
676    let listeners = collect_listening_ports().unwrap_or_default();
677    let mut matching = listeners;
678    if let Some(port) = requested_port {
679        matching.retain(|entry| entry.port == port);
680    }
681    let processes = collect_processes().unwrap_or_default();
682
683    let mut out = String::from("Host inspection: fix_plan\n\n");
684    out.push_str(&format!("- Requested issue: {}\n", issue));
685    out.push_str("- Fix-plan type: port_conflict\n");
686    if let Some(port) = requested_port {
687        out.push_str(&format!("- Requested port: {}\n", port));
688    } else {
689        out.push_str("- Requested port: not parsed from the issue text\n");
690    }
691    out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
692
693    if !matching.is_empty() {
694        out.push_str("\nCurrent listeners:\n");
695        for entry in matching.iter().take(max_entries.min(5)) {
696            let process_name = entry
697                .pid
698                .as_deref()
699                .and_then(|pid| pid.parse::<u32>().ok())
700                .and_then(|pid| {
701                    processes
702                        .iter()
703                        .find(|process| process.pid == pid)
704                        .map(|process| process.name.as_str())
705                })
706                .unwrap_or("unknown");
707            let pid = entry.pid.as_deref().unwrap_or("unknown");
708            out.push_str(&format!(
709                "- {} {} ({}) pid {} process {}\n",
710                entry.protocol, entry.local, entry.state, pid, process_name
711            ));
712        }
713    }
714
715    out.push_str("\nFix plan:\n");
716    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");
717    if !matching.is_empty() {
718        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");
719    } else {
720        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");
721    }
722    out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
723    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");
724    out.push_str(
725        "\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.",
726    );
727    Ok(out.trim_end().to_string())
728}
729
730async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
731    let config = crate::agent::config::load_config();
732    let configured_api = config
733        .api_url
734        .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
735    let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
736    let reachability = probe_http_endpoint(&models_url).await;
737    let embed_model = detect_loaded_embed_model(&configured_api).await;
738
739    let mut out = String::from("Host inspection: fix_plan\n\n");
740    out.push_str(&format!("- Requested issue: {}\n", issue));
741    out.push_str("- Fix-plan type: lm_studio\n");
742    out.push_str(&format!("- Configured API URL: {}\n", configured_api));
743    out.push_str(&format!("- Probe URL: {}\n", models_url));
744    match &reachability {
745        EndpointProbe::Reachable(status) => {
746            out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
747        }
748        EndpointProbe::Unreachable(detail) => {
749            out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
750        }
751    }
752    out.push_str(&format!(
753        "- Embedding model loaded: {}\n",
754        embed_model.as_deref().unwrap_or("none detected")
755    ));
756
757    out.push_str("\nFix plan:\n");
758    match reachability {
759        EndpointProbe::Reachable(_) => {
760            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");
761        }
762        EndpointProbe::Unreachable(_) => {
763            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");
764        }
765    }
766    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");
767    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");
768    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");
769    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");
770    if let Some(model) = embed_model {
771        out.push_str(&format!(
772            "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
773            model
774        ));
775    }
776    if max_entries > 0 {
777        out.push_str(
778            "\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.",
779        );
780    }
781    Ok(out.trim_end().to_string())
782}
783
784fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
785    // Read GPU info from the hardware topic output for grounding
786    #[cfg(target_os = "windows")]
787    let gpu_info = {
788        let out = Command::new("powershell")
789            .args([
790                "-NoProfile",
791                "-NonInteractive",
792                "-Command",
793                "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
794            ])
795            .output()
796            .ok()
797            .and_then(|o| String::from_utf8(o.stdout).ok())
798            .unwrap_or_default();
799        out.trim().to_string()
800    };
801    #[cfg(not(target_os = "windows"))]
802    let gpu_info = String::from("(GPU detection not available on this platform)");
803
804    let mut out = String::from("Host inspection: fix_plan\n\n");
805    out.push_str(&format!("- Requested issue: {}\n", issue));
806    out.push_str("- Fix-plan type: driver_install\n");
807    if !gpu_info.is_empty() {
808        out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
809    }
810    out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
811    out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
812    out.push_str(
813        "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
814    );
815    out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
816    out.push_str("4. Download the latest driver directly from the manufacturer:\n");
817    out.push_str("   - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
818    out.push_str("   - AMD: amd.com/support (use Auto-Detect tool)\n");
819    out.push_str("   - Intel: intel.com/content/www/us/en/download-center/home.html\n");
820    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");
821    out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
822    out.push_str("\nVerification:\n");
823    out.push_str("- After reboot, run in PowerShell:\n  Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
824    out.push_str("- The DriverVersion should match what you installed.\n");
825    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.");
826    Ok(out.trim_end().to_string())
827}
828
829fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
830    // Check Windows edition — Group Policy editor is not available on Home editions
831    #[cfg(target_os = "windows")]
832    let edition = {
833        Command::new("powershell")
834            .args([
835                "-NoProfile",
836                "-NonInteractive",
837                "-Command",
838                "(Get-CimInstance Win32_OperatingSystem).Caption",
839            ])
840            .output()
841            .ok()
842            .and_then(|o| String::from_utf8(o.stdout).ok())
843            .unwrap_or_default()
844            .trim()
845            .to_string()
846    };
847    #[cfg(not(target_os = "windows"))]
848    let edition = String::from("(Windows edition detection not available)");
849
850    let is_home = edition.to_lowercase().contains("home");
851
852    let mut out = String::from("Host inspection: fix_plan\n\n");
853    out.push_str(&format!("- Requested issue: {}\n", issue));
854    out.push_str("- Fix-plan type: group_policy\n");
855    out.push_str(&format!(
856        "- Windows edition detected: {}\n",
857        if edition.is_empty() {
858            "unknown".to_string()
859        } else {
860            edition.clone()
861        }
862    ));
863
864    if is_home {
865        out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
866        out.push_str("Options on Home edition:\n");
867        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");
868        out.push_str(
869            "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
870        );
871        out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
872    } else {
873        out.push_str("\nFix plan — Editing Local Group Policy:\n");
874        out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
875        out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
876        out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
877        out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
878        out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
879        out.push_str("6. To force immediate application, run in an elevated PowerShell:\n  gpupdate /force\n");
880    }
881    out.push_str("\nVerification:\n");
882    out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
883    out.push_str(
884        "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
885    );
886    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.");
887    Ok(out.trim_end().to_string())
888}
889
890fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
891    #[cfg(target_os = "windows")]
892    let profile_state = {
893        Command::new("powershell")
894            .args([
895                "-NoProfile",
896                "-NonInteractive",
897                "-Command",
898                "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
899            ])
900            .output()
901            .ok()
902            .and_then(|o| String::from_utf8(o.stdout).ok())
903            .unwrap_or_default()
904            .trim()
905            .to_string()
906    };
907    #[cfg(not(target_os = "windows"))]
908    let profile_state = String::new();
909
910    let mut out = String::from("Host inspection: fix_plan\n\n");
911    out.push_str(&format!("- Requested issue: {}\n", issue));
912    out.push_str("- Fix-plan type: firewall_rule\n");
913    if !profile_state.is_empty() {
914        out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
915    }
916    out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
917    out.push_str("\nTo ALLOW inbound traffic on a port:\n");
918    out.push_str("  New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
919    out.push_str("\nTo BLOCK outbound traffic to an address:\n");
920    out.push_str("  New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
921    out.push_str("\nTo ALLOW an application through the firewall:\n");
922    out.push_str("  New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
923    out.push_str("\nTo REMOVE a rule you created:\n");
924    out.push_str("  Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
925    out.push_str("\nTo see existing custom rules:\n");
926    out.push_str("  Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
927    out.push_str("\nVerification:\n");
928    out.push_str("- After creating the rule, test reachability from another machine or use:\n  Test-NetConnection -ComputerName localhost -Port 8080\n");
929    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.");
930    Ok(out.trim_end().to_string())
931}
932
933fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
934    let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
935    let ssh_dir = home.join(".ssh");
936    let has_ssh_dir = ssh_dir.exists();
937    let has_ed25519 = ssh_dir.join("id_ed25519").exists();
938    let has_rsa = ssh_dir.join("id_rsa").exists();
939    let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
940
941    let mut out = String::from("Host inspection: fix_plan\n\n");
942    out.push_str(&format!("- Requested issue: {}\n", issue));
943    out.push_str("- Fix-plan type: ssh_key\n");
944    out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
945    out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
946    out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
947    out.push_str(&format!(
948        "- authorized_keys found: {}\n",
949        has_authorized_keys
950    ));
951
952    if has_ed25519 {
953        out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
954    }
955
956    out.push_str("\nFix plan — Generating an SSH key pair:\n");
957    out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
958    out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
959    out.push_str("   ssh-keygen -t ed25519 -C \"your@email.com\"\n");
960    out.push_str(
961        "   - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
962    );
963    out.push_str("   - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
964    out.push_str("3. Start the SSH agent and add your key:\n");
965    out.push_str("   # Windows (PowerShell, run as Admin once to enable the service):\n");
966    out.push_str("   Set-Service -Name ssh-agent -StartupType Automatic\n");
967    out.push_str("   Start-Service ssh-agent\n");
968    out.push_str("   # Then add the key (normal PowerShell):\n");
969    out.push_str("   ssh-add ~/.ssh/id_ed25519\n");
970    out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
971    out.push_str("   # Print your public key:\n");
972    out.push_str("   cat ~/.ssh/id_ed25519.pub\n");
973    out.push_str("   # On the target server, append it:\n");
974    out.push_str("   echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
975    out.push_str("   chmod 600 ~/.ssh/authorized_keys\n");
976    out.push_str("5. Test the connection:\n");
977    out.push_str("   ssh user@server-address\n");
978    out.push_str("\nFor GitHub/GitLab:\n");
979    out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
980    out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
981    out.push_str("- Test: ssh -T git@github.com\n");
982    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.");
983    Ok(out.trim_end().to_string())
984}
985
986fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
987    #[cfg(target_os = "windows")]
988    let wsl_status = {
989        let out = Command::new("wsl")
990            .args(["--status"])
991            .output()
992            .ok()
993            .and_then(|o| {
994                let stdout = String::from_utf8(o.stdout).unwrap_or_default();
995                let stderr = String::from_utf8(o.stderr).unwrap_or_default();
996                Some(format!("{}{}", stdout, stderr))
997            })
998            .unwrap_or_default();
999        out.trim().to_string()
1000    };
1001    #[cfg(not(target_os = "windows"))]
1002    let wsl_status = String::new();
1003
1004    let wsl_installed =
1005        !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1006
1007    let mut out = String::from("Host inspection: fix_plan\n\n");
1008    out.push_str(&format!("- Requested issue: {}\n", issue));
1009    out.push_str("- Fix-plan type: wsl_setup\n");
1010    out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1011    if !wsl_status.is_empty() {
1012        out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1013    }
1014
1015    if wsl_installed {
1016        out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1017        out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1018        out.push_str("   Available distros: wsl --list --online\n");
1019        out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1020        out.push_str("3. Create your Linux username and password when prompted.\n");
1021    } else {
1022        out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1023        out.push_str("1. Open PowerShell as Administrator.\n");
1024        out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1025        out.push_str("   wsl --install\n");
1026        out.push_str("   (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1027        out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1028        out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1029        out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1030        out.push_str("   wsl --set-default-version 2\n");
1031        out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1032        out.push_str("   wsl --install -d Debian\n");
1033        out.push_str("   wsl --list --online   # to see all available distros\n");
1034    }
1035    out.push_str("\nVerification:\n");
1036    out.push_str("- Run: wsl --list --verbose\n");
1037    out.push_str("- You should see your distro with State: Running and Version: 2\n");
1038    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.");
1039    Ok(out.trim_end().to_string())
1040}
1041
1042fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1043    let lower = issue.to_ascii_lowercase();
1044    // Extract service name hints from the issue text
1045    let service_hint = if lower.contains("ssh") {
1046        Some("sshd")
1047    } else if lower.contains("mysql") {
1048        Some("MySQL80")
1049    } else if lower.contains("postgres") || lower.contains("postgresql") {
1050        Some("postgresql")
1051    } else if lower.contains("redis") {
1052        Some("Redis")
1053    } else if lower.contains("nginx") {
1054        Some("nginx")
1055    } else if lower.contains("apache") {
1056        Some("Apache2.4")
1057    } else {
1058        None
1059    };
1060
1061    #[cfg(target_os = "windows")]
1062    let service_state = if let Some(svc) = service_hint {
1063        Command::new("powershell")
1064            .args([
1065                "-NoProfile",
1066                "-NonInteractive",
1067                "-Command",
1068                &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1069            ])
1070            .output()
1071            .ok()
1072            .and_then(|o| String::from_utf8(o.stdout).ok())
1073            .unwrap_or_default()
1074            .trim()
1075            .to_string()
1076    } else {
1077        String::new()
1078    };
1079    #[cfg(not(target_os = "windows"))]
1080    let service_state = String::new();
1081
1082    let mut out = String::from("Host inspection: fix_plan\n\n");
1083    out.push_str(&format!("- Requested issue: {}\n", issue));
1084    out.push_str("- Fix-plan type: service_config\n");
1085    if let Some(svc) = service_hint {
1086        out.push_str(&format!("- Service detected in request: {}\n", svc));
1087    }
1088    if !service_state.is_empty() {
1089        out.push_str(&format!("- Current state: {}\n", service_state));
1090    }
1091
1092    out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1093    out.push_str("\nStart a service:\n");
1094    out.push_str("  Start-Service -Name \"ServiceName\"\n");
1095    out.push_str("\nStop a service:\n");
1096    out.push_str("  Stop-Service -Name \"ServiceName\"\n");
1097    out.push_str("\nRestart a service:\n");
1098    out.push_str("  Restart-Service -Name \"ServiceName\"\n");
1099    out.push_str("\nEnable a service to start automatically:\n");
1100    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1101    out.push_str("\nDisable a service (stops it from auto-starting):\n");
1102    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1103    out.push_str("\nFind the exact service name:\n");
1104    out.push_str("  Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1105    out.push_str("\nVerification:\n");
1106    out.push_str("  Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1107    if let Some(svc) = service_hint {
1108        out.push_str(&format!(
1109            "\nFor your detected service ({}):\n  Get-Service -Name '{}'\n",
1110            svc, svc
1111        ));
1112    }
1113    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.");
1114    Ok(out.trim_end().to_string())
1115}
1116
1117fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1118    #[cfg(target_os = "windows")]
1119    let activation_status = {
1120        Command::new("powershell")
1121            .args([
1122                "-NoProfile",
1123                "-NonInteractive",
1124                "-Command",
1125                "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 + ')' })\" }",
1126            ])
1127            .output()
1128            .ok()
1129            .and_then(|o| String::from_utf8(o.stdout).ok())
1130            .unwrap_or_default()
1131            .trim()
1132            .to_string()
1133    };
1134    #[cfg(not(target_os = "windows"))]
1135    let activation_status = String::new();
1136
1137    let is_licensed = activation_status.to_lowercase().contains("licensed")
1138        && !activation_status.to_lowercase().contains("not licensed");
1139
1140    let mut out = String::from("Host inspection: fix_plan\n\n");
1141    out.push_str(&format!("- Requested issue: {}\n", issue));
1142    out.push_str("- Fix-plan type: windows_activation\n");
1143    if !activation_status.is_empty() {
1144        out.push_str(&format!(
1145            "- Current activation state:\n{}\n",
1146            activation_status
1147        ));
1148    }
1149
1150    if is_licensed {
1151        out.push_str(
1152            "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1153        );
1154        out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1155        out.push_str("   (Forces an online activation attempt)\n");
1156        out.push_str("2. Check activation details: slmgr /dli\n");
1157    } else {
1158        out.push_str("\nFix plan — Activating Windows:\n");
1159        out.push_str("1. Check your current status first:\n");
1160        out.push_str("   slmgr /dli   (basic info)\n");
1161        out.push_str("   slmgr /dlv   (detailed — shows remaining rearms, grace period)\n");
1162        out.push_str("\n2. If you have a retail product key:\n");
1163        out.push_str("   slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX   (install key)\n");
1164        out.push_str("   slmgr /ato                                   (activate online)\n");
1165        out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1166        out.push_str("   - Go to Settings → System → Activation\n");
1167        out.push_str("   - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1168        out.push_str("   - Sign in with the Microsoft account that holds the license\n");
1169        out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1170        out.push_str("   - Contact your IT department for the KMS server address\n");
1171        out.push_str("   - Set KMS host: slmgr /skms kms.yourorg.com\n");
1172        out.push_str("   - Activate:    slmgr /ato\n");
1173    }
1174    out.push_str("\nVerification:\n");
1175    out.push_str("  slmgr /dli   — should show 'License Status: Licensed'\n");
1176    out.push_str("  Or: Settings → System → Activation → 'Windows is activated'\n");
1177    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.");
1178    Ok(out.trim_end().to_string())
1179}
1180
1181fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1182    let mut out = String::from("Host inspection: fix_plan\n\n");
1183    out.push_str(&format!("- Requested issue: {}\n", issue));
1184    out.push_str("- Fix-plan type: registry_edit\n");
1185    out.push_str(
1186        "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1187    );
1188    out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1189    out.push_str("\n1. Back up before you touch anything:\n");
1190    out.push_str("   # Export the key you're about to change (PowerShell):\n");
1191    out.push_str("   reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1192    out.push_str("   # Or export the whole registry (takes a while):\n");
1193    out.push_str("   reg export HKLM C:\\backup\\HKLM_full.reg\n");
1194    out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1195    out.push_str("   Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1196    out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1197    out.push_str(
1198        "   Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1199    );
1200    out.push_str("\n4. Create a new key:\n");
1201    out.push_str("   New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1202    out.push_str("\n5. Delete a value:\n");
1203    out.push_str("   Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1204    out.push_str("\n6. Restore from backup if something breaks:\n");
1205    out.push_str("   reg import C:\\backup\\MyKey_backup.reg\n");
1206    out.push_str("\nCommon registry hives:\n");
1207    out.push_str("  HKLM = HKEY_LOCAL_MACHINE  (machine-wide, requires Admin)\n");
1208    out.push_str("  HKCU = HKEY_CURRENT_USER   (current user, no elevation needed)\n");
1209    out.push_str("  HKCR = HKEY_CLASSES_ROOT    (file associations)\n");
1210    out.push_str("\nVerification:\n");
1211    out.push_str("  Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1212    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.");
1213    Ok(out.trim_end().to_string())
1214}
1215
1216fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1217    let mut out = String::from("Host inspection: fix_plan\n\n");
1218    out.push_str(&format!("- Requested issue: {}\n", issue));
1219    out.push_str("- Fix-plan type: scheduled_task_create\n");
1220    out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1221    out.push_str("\nExample: Run a script at 9 AM every day\n");
1222    out.push_str("  $action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1223    out.push_str("  $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1224    out.push_str("  Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1225    out.push_str("\nExample: Run at Windows startup\n");
1226    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1227    out.push_str("  Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1228    out.push_str("\nExample: Run at user logon\n");
1229    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1230    out.push_str(
1231        "  Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1232    );
1233    out.push_str("\nExample: Run every 30 minutes\n");
1234    out.push_str("  $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1235    out.push_str("\nView all tasks:\n");
1236    out.push_str("  Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1237    out.push_str("\nDelete a task:\n");
1238    out.push_str("  Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1239    out.push_str("\nRun a task immediately:\n");
1240    out.push_str("  Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1241    out.push_str("\nVerification:\n");
1242    out.push_str("  Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1243    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.");
1244    Ok(out.trim_end().to_string())
1245}
1246
1247fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1248    #[cfg(target_os = "windows")]
1249    let disk_info = {
1250        Command::new("powershell")
1251            .args([
1252                "-NoProfile",
1253                "-NonInteractive",
1254                "-Command",
1255                "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\" }",
1256            ])
1257            .output()
1258            .ok()
1259            .and_then(|o| String::from_utf8(o.stdout).ok())
1260            .unwrap_or_default()
1261            .trim()
1262            .to_string()
1263    };
1264    #[cfg(not(target_os = "windows"))]
1265    let disk_info = String::new();
1266
1267    let mut out = String::from("Host inspection: fix_plan\n\n");
1268    out.push_str(&format!("- Requested issue: {}\n", issue));
1269    out.push_str("- Fix-plan type: disk_cleanup\n");
1270    if !disk_info.is_empty() {
1271        out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1272    }
1273    out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1274    out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1275    out.push_str("   cleanmgr /sageset:1    (configure what to clean)\n");
1276    out.push_str("   cleanmgr /sagerun:1    (run the cleanup)\n");
1277    out.push_str("   Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1278    out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1279    out.push_str("   Stop-Service wuauserv\n");
1280    out.push_str("   Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1281    out.push_str("   Start-Service wuauserv\n");
1282    out.push_str("\n3. Clear Windows Temp folder:\n");
1283    out.push_str("   Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1284    out.push_str(
1285        "   Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1286    );
1287    out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1288    out.push_str("   - Rust build artifacts: cargo clean  (inside each project)\n");
1289    out.push_str("   - npm cache:  npm cache clean --force\n");
1290    out.push_str("   - pip cache:  pip cache purge\n");
1291    out.push_str(
1292        "   - Docker:     docker system prune -a  (removes all unused images/containers)\n",
1293    );
1294    out.push_str("   - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force  (will redownload on next build)\n");
1295    out.push_str("\n5. Check for large files:\n");
1296    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");
1297    out.push_str("\nVerification:\n");
1298    out.push_str(
1299        "  Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1300    );
1301    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.");
1302    Ok(out.trim_end().to_string())
1303}
1304
1305fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1306    let mut out = String::from("Host inspection: fix_plan\n\n");
1307    out.push_str(&format!("- Requested issue: {}\n", issue));
1308    out.push_str("- Fix-plan type: generic\n");
1309    out.push_str(
1310        "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1311         Structured lanes available:\n\
1312         - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1313         - Port conflict (address already in use, what owns port)\n\
1314         - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1315         - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1316         - Group Policy (gpedit, local policy, administrative template)\n\
1317         - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1318         - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1319         - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1320         - Service config (start/stop/restart/enable/disable a service)\n\
1321         - Windows activation (product key, not activated, kms)\n\
1322         - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1323         - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1324         - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1325         - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1326    );
1327    Ok(out.trim_end().to_string())
1328}
1329
1330fn inspect_resource_load() -> Result<String, String> {
1331    #[cfg(target_os = "windows")]
1332    {
1333        let output = Command::new("powershell")
1334            .args([
1335                "-NoProfile",
1336                "-Command",
1337                "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1338            ])
1339            .output()
1340            .map_err(|e| format!("Failed to run powershell: {e}"))?;
1341
1342        let text = String::from_utf8_lossy(&output.stdout);
1343        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1344
1345        let cpu_load = lines
1346            .next()
1347            .and_then(|l| l.parse::<u32>().ok())
1348            .unwrap_or(0);
1349        let mem_json = lines.collect::<Vec<_>>().join("");
1350        let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1351
1352        let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1353        let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1354        let used_kb = total_kb.saturating_sub(free_kb);
1355        let mem_percent = if total_kb > 0 {
1356            (used_kb * 100) / total_kb
1357        } else {
1358            0
1359        };
1360
1361        let mut out = String::from("Host inspection: resource_load\n\n");
1362        out.push_str("**System Performance Summary:**\n");
1363        out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1364        out.push_str(&format!(
1365            "- Memory Usage: {} / {} ({}%)\n",
1366            human_bytes(used_kb * 1024),
1367            human_bytes(total_kb * 1024),
1368            mem_percent
1369        ));
1370
1371        if cpu_load > 85 {
1372            out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1373        }
1374        if mem_percent > 90 {
1375            out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1376        }
1377
1378        Ok(out)
1379    }
1380    #[cfg(not(target_os = "windows"))]
1381    {
1382        Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1383    }
1384}
1385
1386#[derive(Debug)]
1387enum EndpointProbe {
1388    Reachable(u16),
1389    Unreachable(String),
1390}
1391
1392async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1393    let client = match reqwest::Client::builder()
1394        .timeout(std::time::Duration::from_secs(3))
1395        .build()
1396    {
1397        Ok(client) => client,
1398        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1399    };
1400
1401    match client.get(url).send().await {
1402        Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1403        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1404    }
1405}
1406
1407async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1408    let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1409    let url = format!("{}/api/v0/models", base);
1410    let client = reqwest::Client::builder()
1411        .timeout(std::time::Duration::from_secs(3))
1412        .build()
1413        .ok()?;
1414
1415    #[derive(serde::Deserialize)]
1416    struct ModelList {
1417        data: Vec<ModelEntry>,
1418    }
1419    #[derive(serde::Deserialize)]
1420    struct ModelEntry {
1421        id: String,
1422        #[serde(rename = "type", default)]
1423        model_type: String,
1424        #[serde(default)]
1425        state: String,
1426    }
1427
1428    let response = client.get(url).send().await.ok()?;
1429    let models = response.json::<ModelList>().await.ok()?;
1430    models
1431        .data
1432        .into_iter()
1433        .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1434        .map(|model| model.id)
1435}
1436
1437fn first_port_in_text(text: &str) -> Option<u16> {
1438    text.split(|c: char| !c.is_ascii_digit())
1439        .find(|fragment| !fragment.is_empty())
1440        .and_then(|fragment| fragment.parse::<u16>().ok())
1441}
1442
1443fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1444    let mut processes = collect_processes()?;
1445    if let Some(filter) = name_filter.as_deref() {
1446        let lowered = filter.to_ascii_lowercase();
1447        processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1448    }
1449    processes.sort_by(|a, b| {
1450        b.memory_bytes
1451            .cmp(&a.memory_bytes)
1452            .then_with(|| a.name.cmp(&b.name))
1453            .then_with(|| a.pid.cmp(&b.pid))
1454    });
1455
1456    let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1457
1458    let mut out = String::from("Host inspection: processes\n\n");
1459    if let Some(filter) = name_filter.as_deref() {
1460        out.push_str(&format!("- Filter name: {}\n", filter));
1461    }
1462    out.push_str(&format!("- Processes found: {}\n", processes.len()));
1463    out.push_str(&format!(
1464        "- Total reported working set: {}\n",
1465        human_bytes(total_memory)
1466    ));
1467
1468    if processes.is_empty() {
1469        out.push_str("\nNo running processes matched.");
1470        return Ok(out);
1471    }
1472
1473    out.push_str("\nTop processes by resource usage:\n");
1474    for entry in processes.iter().take(max_entries) {
1475        let cpu_str = entry
1476            .cpu_seconds
1477            .map(|s| format!(" [CPU: {:.1}s]", s))
1478            .unwrap_or_default();
1479        let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1480            format!(" [I/O R:{}/W:{}]", r, w)
1481        } else {
1482            " [I/O unknown]".to_string()
1483        };
1484        out.push_str(&format!(
1485            "- {} (pid {}) - {}{}{}{}\n",
1486            entry.name,
1487            entry.pid,
1488            human_bytes(entry.memory_bytes),
1489            cpu_str,
1490            io_str,
1491            entry
1492                .detail
1493                .as_deref()
1494                .map(|detail| format!(" [{}]", detail))
1495                .unwrap_or_default()
1496        ));
1497    }
1498    if processes.len() > max_entries {
1499        out.push_str(&format!(
1500            "- ... {} more processes omitted\n",
1501            processes.len() - max_entries
1502        ));
1503    }
1504
1505    Ok(out.trim_end().to_string())
1506}
1507
1508fn inspect_network(max_entries: usize) -> Result<String, String> {
1509    let adapters = collect_network_adapters()?;
1510    let active_count = adapters
1511        .iter()
1512        .filter(|adapter| adapter.is_active())
1513        .count();
1514    let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1515
1516    let mut out = String::from("Host inspection: network\n\n");
1517    out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1518    out.push_str(&format!("- Active adapters: {}\n", active_count));
1519    out.push_str(&format!(
1520        "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1521        exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1522    ));
1523
1524    if adapters.is_empty() {
1525        out.push_str("\nNo adapter details were detected.");
1526        return Ok(out);
1527    }
1528
1529    out.push_str("\nAdapter summary:\n");
1530    for adapter in adapters.iter().take(max_entries) {
1531        let status = if adapter.is_active() {
1532            "active"
1533        } else if adapter.disconnected {
1534            "disconnected"
1535        } else {
1536            "idle"
1537        };
1538        let mut details = vec![status.to_string()];
1539        if !adapter.ipv4.is_empty() {
1540            details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1541        }
1542        if !adapter.ipv6.is_empty() {
1543            details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1544        }
1545        if !adapter.gateways.is_empty() {
1546            details.push(format!("gateway {}", adapter.gateways.join(", ")));
1547        }
1548        if !adapter.dns_servers.is_empty() {
1549            details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1550        }
1551        out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1552    }
1553    if adapters.len() > max_entries {
1554        out.push_str(&format!(
1555            "- ... {} more adapters omitted\n",
1556            adapters.len() - max_entries
1557        ));
1558    }
1559
1560    Ok(out.trim_end().to_string())
1561}
1562
1563fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1564    let mut services = collect_services()?;
1565    if let Some(filter) = name_filter.as_deref() {
1566        let lowered = filter.to_ascii_lowercase();
1567        services.retain(|entry| {
1568            entry.name.to_ascii_lowercase().contains(&lowered)
1569                || entry
1570                    .display_name
1571                    .as_deref()
1572                    .unwrap_or("")
1573                    .to_ascii_lowercase()
1574                    .contains(&lowered)
1575        });
1576    }
1577
1578    services.sort_by(|a, b| {
1579        service_status_rank(&a.status)
1580            .cmp(&service_status_rank(&b.status))
1581            .then_with(|| a.name.cmp(&b.name))
1582    });
1583
1584    let running = services
1585        .iter()
1586        .filter(|entry| {
1587            entry.status.eq_ignore_ascii_case("running")
1588                || entry.status.eq_ignore_ascii_case("active")
1589        })
1590        .count();
1591    let failed = services
1592        .iter()
1593        .filter(|entry| {
1594            entry.status.eq_ignore_ascii_case("failed")
1595                || entry.status.eq_ignore_ascii_case("error")
1596                || entry.status.eq_ignore_ascii_case("stopped")
1597        })
1598        .count();
1599
1600    let mut out = String::from("Host inspection: services\n\n");
1601    if let Some(filter) = name_filter.as_deref() {
1602        out.push_str(&format!("- Filter name: {}\n", filter));
1603    }
1604    out.push_str(&format!("- Services found: {}\n", services.len()));
1605    out.push_str(&format!("- Running/active: {}\n", running));
1606    out.push_str(&format!("- Failed/stopped: {}\n", failed));
1607
1608    if services.is_empty() {
1609        out.push_str("\nNo services matched.");
1610        return Ok(out);
1611    }
1612
1613    // Split into running and stopped sections so both are always visible.
1614    let per_section = (max_entries / 2).max(5);
1615
1616    let running_services: Vec<_> = services
1617        .iter()
1618        .filter(|e| {
1619            e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
1620        })
1621        .collect();
1622    let stopped_services: Vec<_> = services
1623        .iter()
1624        .filter(|e| {
1625            e.status.eq_ignore_ascii_case("stopped")
1626                || e.status.eq_ignore_ascii_case("failed")
1627                || e.status.eq_ignore_ascii_case("error")
1628        })
1629        .collect();
1630
1631    let fmt_entry = |entry: &&ServiceEntry| {
1632        let startup = entry
1633            .startup
1634            .as_deref()
1635            .map(|v| format!(" | startup {}", v))
1636            .unwrap_or_default();
1637        let display = entry
1638            .display_name
1639            .as_deref()
1640            .filter(|v| *v != &entry.name)
1641            .map(|v| format!(" [{}]", v))
1642            .unwrap_or_default();
1643        format!("- {}{} - {}{}\n", entry.name, display, entry.status, startup)
1644    };
1645
1646    out.push_str(&format!(
1647        "\nRunning services ({} total, showing up to {}):\n",
1648        running_services.len(),
1649        per_section
1650    ));
1651    for entry in running_services.iter().take(per_section) {
1652        out.push_str(&fmt_entry(entry));
1653    }
1654    if running_services.len() > per_section {
1655        out.push_str(&format!(
1656            "- ... {} more running services omitted\n",
1657            running_services.len() - per_section
1658        ));
1659    }
1660
1661    out.push_str(&format!(
1662        "\nStopped/failed services ({} total, showing up to {}):\n",
1663        stopped_services.len(),
1664        per_section
1665    ));
1666    for entry in stopped_services.iter().take(per_section) {
1667        out.push_str(&fmt_entry(entry));
1668    }
1669    if stopped_services.len() > per_section {
1670        out.push_str(&format!(
1671            "- ... {} more stopped services omitted\n",
1672            stopped_services.len() - per_section
1673        ));
1674    }
1675
1676    Ok(out.trim_end().to_string())
1677}
1678
1679async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
1680    inspect_directory("Disk", path, max_entries).await
1681}
1682
1683fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
1684    let mut listeners = collect_listening_ports()?;
1685    if let Some(port) = port_filter {
1686        listeners.retain(|entry| entry.port == port);
1687    }
1688    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
1689
1690    let mut out = String::from("Host inspection: ports\n\n");
1691    if let Some(port) = port_filter {
1692        out.push_str(&format!("- Filter port: {}\n", port));
1693    }
1694    out.push_str(&format!(
1695        "- Listening endpoints found: {}\n",
1696        listeners.len()
1697    ));
1698
1699    if listeners.is_empty() {
1700        out.push_str("\nNo listening endpoints matched.");
1701        return Ok(out);
1702    }
1703
1704    out.push_str("\nListening endpoints:\n");
1705    for entry in listeners.iter().take(max_entries) {
1706        let pid_str = entry
1707            .pid
1708            .as_deref()
1709            .map(|p| format!(" pid {}", p))
1710            .unwrap_or_default();
1711        let name_str = entry
1712            .process_name
1713            .as_deref()
1714            .map(|n| format!(" [{}]", n))
1715            .unwrap_or_default();
1716        out.push_str(&format!(
1717            "- {} {} ({}){}{}\n",
1718            entry.protocol, entry.local, entry.state, pid_str, name_str
1719        ));
1720    }
1721    if listeners.len() > max_entries {
1722        out.push_str(&format!(
1723            "- ... {} more listening endpoints omitted\n",
1724            listeners.len() - max_entries
1725        ));
1726    }
1727
1728    Ok(out.trim_end().to_string())
1729}
1730
1731fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
1732    if !path.exists() {
1733        return Err(format!("Path does not exist: {}", path.display()));
1734    }
1735    if !path.is_dir() {
1736        return Err(format!("Path is not a directory: {}", path.display()));
1737    }
1738
1739    let markers = collect_project_markers(&path);
1740    let hematite_state = collect_hematite_state(&path);
1741    let git_state = inspect_git_state(&path);
1742    let release_state = inspect_release_artifacts(&path);
1743
1744    let mut out = String::from("Host inspection: repo_doctor\n\n");
1745    out.push_str(&format!("- Path: {}\n", path.display()));
1746    out.push_str(&format!(
1747        "- Workspace mode: {}\n",
1748        workspace_mode_for_path(&path)
1749    ));
1750
1751    if markers.is_empty() {
1752        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");
1753    } else {
1754        out.push_str("- Project markers:\n");
1755        for marker in markers.iter().take(max_entries) {
1756            out.push_str(&format!("  - {}\n", marker));
1757        }
1758    }
1759
1760    match git_state {
1761        Some(git) => {
1762            out.push_str(&format!("- Git root: {}\n", git.root.display()));
1763            out.push_str(&format!("- Git branch: {}\n", git.branch));
1764            out.push_str(&format!("- Git status: {}\n", git.status_label()));
1765        }
1766        None => out.push_str("- Git: not inside a detected work tree\n"),
1767    }
1768
1769    out.push_str(&format!(
1770        "- Hematite docs/imports/reports: {}/{}/{}\n",
1771        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
1772    ));
1773    if hematite_state.workspace_profile {
1774        out.push_str("- Workspace profile: present\n");
1775    } else {
1776        out.push_str("- Workspace profile: absent\n");
1777    }
1778
1779    if let Some(release) = release_state {
1780        out.push_str(&format!("- Cargo version: {}\n", release.version));
1781        out.push_str(&format!(
1782            "- Windows artifacts for current version: {}/{}/{}\n",
1783            bool_label(release.portable_dir),
1784            bool_label(release.portable_zip),
1785            bool_label(release.setup_exe)
1786        ));
1787    }
1788
1789    Ok(out.trim_end().to_string())
1790}
1791
1792async fn inspect_known_directory(
1793    label: &str,
1794    path: Option<PathBuf>,
1795    max_entries: usize,
1796) -> Result<String, String> {
1797    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
1798    inspect_directory(label, path, max_entries).await
1799}
1800
1801async fn inspect_directory(
1802    label: &str,
1803    path: PathBuf,
1804    max_entries: usize,
1805) -> Result<String, String> {
1806    let label = label.to_string();
1807    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
1808        .await
1809        .map_err(|e| format!("inspect_host task failed: {e}"))?
1810}
1811
1812fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
1813    if !path.exists() {
1814        return Err(format!("Path does not exist: {}", path.display()));
1815    }
1816    if !path.is_dir() {
1817        return Err(format!("Path is not a directory: {}", path.display()));
1818    }
1819
1820    let mut top_level_entries = Vec::new();
1821    for entry in fs::read_dir(path)
1822        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
1823    {
1824        match entry {
1825            Ok(entry) => top_level_entries.push(entry),
1826            Err(_) => continue,
1827        }
1828    }
1829    top_level_entries.sort_by_key(|entry| entry.file_name());
1830
1831    let top_level_count = top_level_entries.len();
1832    let mut sample_names = Vec::new();
1833    let mut largest_entries = Vec::new();
1834    let mut aggregate = PathAggregate::default();
1835    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
1836
1837    for entry in top_level_entries {
1838        let name = entry.file_name().to_string_lossy().to_string();
1839        if sample_names.len() < max_entries {
1840            sample_names.push(name.clone());
1841        }
1842        let kind = match entry.file_type() {
1843            Ok(ft) if ft.is_dir() => "dir",
1844            Ok(ft) if ft.is_symlink() => "symlink",
1845            _ => "file",
1846        };
1847        let stats = measure_path(&entry.path(), &mut budget);
1848        aggregate.merge(&stats);
1849        largest_entries.push(LargestEntry {
1850            name,
1851            kind,
1852            bytes: stats.total_bytes,
1853        });
1854    }
1855
1856    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
1857
1858    let mut out = format!("Directory inspection: {}\n\n", label);
1859    out.push_str(&format!("- Path: {}\n", path.display()));
1860    out.push_str(&format!("- Top-level items: {}\n", top_level_count));
1861    out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
1862    out.push_str(&format!(
1863        "- Recursive directories: {}\n",
1864        aggregate.dir_count
1865    ));
1866    out.push_str(&format!(
1867        "- Total size: {}{}\n",
1868        human_bytes(aggregate.total_bytes),
1869        if aggregate.partial {
1870            " (partial scan)"
1871        } else {
1872            ""
1873        }
1874    ));
1875    if aggregate.skipped_entries > 0 {
1876        out.push_str(&format!(
1877            "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
1878            aggregate.skipped_entries
1879        ));
1880    }
1881
1882    if !largest_entries.is_empty() {
1883        out.push_str("\nLargest top-level entries:\n");
1884        for entry in largest_entries.iter().take(max_entries) {
1885            out.push_str(&format!(
1886                "- {} [{}] - {}\n",
1887                entry.name,
1888                entry.kind,
1889                human_bytes(entry.bytes)
1890            ));
1891        }
1892    }
1893
1894    if !sample_names.is_empty() {
1895        out.push_str("\nSample names:\n");
1896        for name in sample_names {
1897            out.push_str(&format!("- {}\n", name));
1898        }
1899    }
1900
1901    Ok(out.trim_end().to_string())
1902}
1903
1904fn resolve_path(raw: &str) -> Result<PathBuf, String> {
1905    let trimmed = raw.trim();
1906    if trimmed.is_empty() {
1907        return Err("Path must not be empty.".to_string());
1908    }
1909
1910    if let Some(rest) = trimmed
1911        .strip_prefix("~/")
1912        .or_else(|| trimmed.strip_prefix("~\\"))
1913    {
1914        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
1915        return Ok(home.join(rest));
1916    }
1917
1918    let path = PathBuf::from(trimmed);
1919    if path.is_absolute() {
1920        Ok(path)
1921    } else {
1922        let cwd =
1923            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
1924        let full_path = cwd.join(&path);
1925
1926        // Heuristic: If it's a relative path to .hematite or hematite.exe and doesn't exist here,
1927        // check the user's home directory.
1928        if !full_path.exists()
1929            && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
1930        {
1931            if let Some(home) = home::home_dir() {
1932                let home_path = home.join(trimmed);
1933                if home_path.exists() {
1934                    return Ok(home_path);
1935                }
1936            }
1937        }
1938
1939        Ok(full_path)
1940    }
1941}
1942
1943fn workspace_mode_label(workspace_root: &Path) -> &'static str {
1944    workspace_mode_for_path(workspace_root)
1945}
1946
1947fn workspace_mode_for_path(path: &Path) -> &'static str {
1948    if is_project_marker_path(path) {
1949        "project"
1950    } else if path.join(".hematite").join("docs").exists()
1951        || path.join(".hematite").join("imports").exists()
1952        || path.join(".hematite").join("reports").exists()
1953    {
1954        "docs-only"
1955    } else {
1956        "general directory"
1957    }
1958}
1959
1960fn is_project_marker_path(path: &Path) -> bool {
1961    [
1962        "Cargo.toml",
1963        "package.json",
1964        "pyproject.toml",
1965        "go.mod",
1966        "composer.json",
1967        "requirements.txt",
1968        "Makefile",
1969        "justfile",
1970    ]
1971    .iter()
1972    .any(|name| path.join(name).exists())
1973        || path.join(".git").exists()
1974}
1975
1976fn preferred_shell_label() -> &'static str {
1977    #[cfg(target_os = "windows")]
1978    {
1979        "PowerShell"
1980    }
1981    #[cfg(not(target_os = "windows"))]
1982    {
1983        "sh"
1984    }
1985}
1986
1987fn desktop_dir() -> Option<PathBuf> {
1988    home::home_dir().map(|home| home.join("Desktop"))
1989}
1990
1991fn downloads_dir() -> Option<PathBuf> {
1992    home::home_dir().map(|home| home.join("Downloads"))
1993}
1994
1995fn count_top_level_items(path: &Path) -> Result<usize, String> {
1996    let mut count = 0usize;
1997    for entry in
1998        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
1999    {
2000        if entry.is_ok() {
2001            count += 1;
2002        }
2003    }
2004    Ok(count)
2005}
2006
2007#[derive(Default)]
2008struct PathAggregate {
2009    total_bytes: u64,
2010    file_count: u64,
2011    dir_count: u64,
2012    skipped_entries: u64,
2013    partial: bool,
2014}
2015
2016impl PathAggregate {
2017    fn merge(&mut self, other: &PathAggregate) {
2018        self.total_bytes += other.total_bytes;
2019        self.file_count += other.file_count;
2020        self.dir_count += other.dir_count;
2021        self.skipped_entries += other.skipped_entries;
2022        self.partial |= other.partial;
2023    }
2024}
2025
2026struct LargestEntry {
2027    name: String,
2028    kind: &'static str,
2029    bytes: u64,
2030}
2031
2032fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2033    if *budget == 0 {
2034        return PathAggregate {
2035            partial: true,
2036            skipped_entries: 1,
2037            ..PathAggregate::default()
2038        };
2039    }
2040    *budget -= 1;
2041
2042    let metadata = match fs::symlink_metadata(path) {
2043        Ok(metadata) => metadata,
2044        Err(_) => {
2045            return PathAggregate {
2046                skipped_entries: 1,
2047                ..PathAggregate::default()
2048            }
2049        }
2050    };
2051
2052    let file_type = metadata.file_type();
2053    if file_type.is_symlink() {
2054        return PathAggregate {
2055            skipped_entries: 1,
2056            ..PathAggregate::default()
2057        };
2058    }
2059
2060    if metadata.is_file() {
2061        return PathAggregate {
2062            total_bytes: metadata.len(),
2063            file_count: 1,
2064            ..PathAggregate::default()
2065        };
2066    }
2067
2068    if !metadata.is_dir() {
2069        return PathAggregate::default();
2070    }
2071
2072    let mut aggregate = PathAggregate {
2073        dir_count: 1,
2074        ..PathAggregate::default()
2075    };
2076
2077    let read_dir = match fs::read_dir(path) {
2078        Ok(read_dir) => read_dir,
2079        Err(_) => {
2080            aggregate.skipped_entries += 1;
2081            return aggregate;
2082        }
2083    };
2084
2085    for child in read_dir {
2086        match child {
2087            Ok(child) => {
2088                let child_stats = measure_path(&child.path(), budget);
2089                aggregate.merge(&child_stats);
2090            }
2091            Err(_) => aggregate.skipped_entries += 1,
2092        }
2093    }
2094
2095    aggregate
2096}
2097
2098struct PathAnalysis {
2099    total_entries: usize,
2100    unique_entries: usize,
2101    entries: Vec<String>,
2102    duplicate_entries: Vec<String>,
2103    missing_entries: Vec<String>,
2104}
2105
2106fn analyze_path_env() -> PathAnalysis {
2107    let mut entries = Vec::new();
2108    let mut duplicate_entries = Vec::new();
2109    let mut missing_entries = Vec::new();
2110    let mut seen = HashSet::new();
2111
2112    let raw_path = std::env::var_os("PATH").unwrap_or_default();
2113    for path in std::env::split_paths(&raw_path) {
2114        let display = path.display().to_string();
2115        if display.trim().is_empty() {
2116            continue;
2117        }
2118
2119        let normalized = normalize_path_entry(&display);
2120        if !seen.insert(normalized) {
2121            duplicate_entries.push(display.clone());
2122        }
2123        if !path.exists() {
2124            missing_entries.push(display.clone());
2125        }
2126        entries.push(display);
2127    }
2128
2129    let total_entries = entries.len();
2130    let unique_entries = seen.len();
2131
2132    PathAnalysis {
2133        total_entries,
2134        unique_entries,
2135        entries,
2136        duplicate_entries,
2137        missing_entries,
2138    }
2139}
2140
2141fn normalize_path_entry(value: &str) -> String {
2142    #[cfg(target_os = "windows")]
2143    {
2144        value
2145            .replace('/', "\\")
2146            .trim_end_matches(['\\', '/'])
2147            .to_ascii_lowercase()
2148    }
2149    #[cfg(not(target_os = "windows"))]
2150    {
2151        value.trim_end_matches('/').to_string()
2152    }
2153}
2154
2155struct ToolchainReport {
2156    found: Vec<(String, String)>,
2157    missing: Vec<String>,
2158}
2159
2160struct PackageManagerReport {
2161    found: Vec<(String, String)>,
2162}
2163
2164#[derive(Debug, Clone)]
2165struct ProcessEntry {
2166    name: String,
2167    pid: u32,
2168    memory_bytes: u64,
2169    cpu_seconds: Option<f64>,
2170    read_ops: Option<u64>,
2171    write_ops: Option<u64>,
2172    detail: Option<String>,
2173}
2174
2175#[derive(Debug, Clone)]
2176struct ServiceEntry {
2177    name: String,
2178    status: String,
2179    startup: Option<String>,
2180    display_name: Option<String>,
2181}
2182
2183#[derive(Debug, Clone, Default)]
2184struct NetworkAdapter {
2185    name: String,
2186    ipv4: Vec<String>,
2187    ipv6: Vec<String>,
2188    gateways: Vec<String>,
2189    dns_servers: Vec<String>,
2190    disconnected: bool,
2191}
2192
2193impl NetworkAdapter {
2194    fn is_active(&self) -> bool {
2195        !self.disconnected
2196            && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2197    }
2198}
2199
2200#[derive(Debug, Clone, Copy, Default)]
2201struct ListenerExposureSummary {
2202    loopback_only: usize,
2203    wildcard_public: usize,
2204    specific_bind: usize,
2205}
2206
2207#[derive(Debug, Clone)]
2208struct ListeningPort {
2209    protocol: String,
2210    local: String,
2211    port: u16,
2212    state: String,
2213    pid: Option<String>,
2214    process_name: Option<String>,
2215}
2216
2217fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2218    #[cfg(target_os = "windows")]
2219    {
2220        collect_windows_listening_ports()
2221    }
2222    #[cfg(not(target_os = "windows"))]
2223    {
2224        collect_unix_listening_ports()
2225    }
2226}
2227
2228fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2229    #[cfg(target_os = "windows")]
2230    {
2231        collect_windows_network_adapters()
2232    }
2233    #[cfg(not(target_os = "windows"))]
2234    {
2235        collect_unix_network_adapters()
2236    }
2237}
2238
2239fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2240    #[cfg(target_os = "windows")]
2241    {
2242        collect_windows_services()
2243    }
2244    #[cfg(not(target_os = "windows"))]
2245    {
2246        collect_unix_services()
2247    }
2248}
2249
2250#[cfg(target_os = "windows")]
2251fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2252    let output = Command::new("netstat")
2253        .args(["-ano", "-p", "tcp"])
2254        .output()
2255        .map_err(|e| format!("Failed to run netstat: {e}"))?;
2256    if !output.status.success() {
2257        return Err("netstat returned a non-success status.".to_string());
2258    }
2259
2260    let text = String::from_utf8_lossy(&output.stdout);
2261    let mut listeners = Vec::new();
2262    for line in text.lines() {
2263        let trimmed = line.trim();
2264        if !trimmed.starts_with("TCP") {
2265            continue;
2266        }
2267        let cols: Vec<&str> = trimmed.split_whitespace().collect();
2268        if cols.len() < 5 || cols[3] != "LISTENING" {
2269            continue;
2270        }
2271        let Some(port) = extract_port_from_socket(cols[1]) else {
2272            continue;
2273        };
2274        listeners.push(ListeningPort {
2275            protocol: cols[0].to_string(),
2276            local: cols[1].to_string(),
2277            port,
2278            state: cols[3].to_string(),
2279            pid: Some(cols[4].to_string()),
2280            process_name: None,
2281        });
2282    }
2283
2284    // Enrich with process names via PowerShell — works without elevation for
2285    // most user-space processes. System processes (PID 4, etc.) stay unnamed.
2286    let unique_pids: Vec<String> = listeners
2287        .iter()
2288        .filter_map(|l| l.pid.clone())
2289        .collect::<HashSet<_>>()
2290        .into_iter()
2291        .collect();
2292
2293    if !unique_pids.is_empty() {
2294        let pid_list = unique_pids.join(",");
2295        let ps_cmd = format!(
2296            "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2297            pid_list
2298        );
2299        if let Ok(ps_out) = Command::new("powershell")
2300            .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2301            .output()
2302        {
2303            let mut pid_map = std::collections::HashMap::<String, String>::new();
2304            let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2305            for line in ps_text.lines() {
2306                let parts: Vec<&str> = line.split_whitespace().collect();
2307                if parts.len() >= 2 {
2308                    pid_map.insert(parts[0].to_string(), parts[1].to_string());
2309                }
2310            }
2311            for listener in &mut listeners {
2312                if let Some(pid) = &listener.pid {
2313                    listener.process_name = pid_map.get(pid).cloned();
2314                }
2315            }
2316        }
2317    }
2318
2319    Ok(listeners)
2320}
2321
2322#[cfg(not(target_os = "windows"))]
2323fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2324    let output = Command::new("ss")
2325        .args(["-ltn"])
2326        .output()
2327        .map_err(|e| format!("Failed to run ss: {e}"))?;
2328    if !output.status.success() {
2329        return Err("ss returned a non-success status.".to_string());
2330    }
2331
2332    let text = String::from_utf8_lossy(&output.stdout);
2333    let mut listeners = Vec::new();
2334    for line in text.lines().skip(1) {
2335        let cols: Vec<&str> = line.split_whitespace().collect();
2336        if cols.len() < 4 {
2337            continue;
2338        }
2339        let Some(port) = extract_port_from_socket(cols[3]) else {
2340            continue;
2341        };
2342        listeners.push(ListeningPort {
2343            protocol: "tcp".to_string(),
2344            local: cols[3].to_string(),
2345            port,
2346            state: cols[0].to_string(),
2347            pid: None,
2348            process_name: None,
2349        });
2350    }
2351
2352    Ok(listeners)
2353}
2354
2355fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
2356    #[cfg(target_os = "windows")]
2357    {
2358        collect_windows_processes()
2359    }
2360    #[cfg(not(target_os = "windows"))]
2361    {
2362        collect_unix_processes()
2363    }
2364}
2365
2366#[cfg(target_os = "windows")]
2367fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
2368    let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName | ConvertTo-Json -Compress";
2369    let output = Command::new("powershell")
2370        .args(["-NoProfile", "-Command", command])
2371        .output()
2372        .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
2373    if !output.status.success() {
2374        return Err("PowerShell service inspection returned a non-success status.".to_string());
2375    }
2376
2377    parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
2378}
2379
2380#[cfg(not(target_os = "windows"))]
2381fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
2382    let status_output = Command::new("systemctl")
2383        .args([
2384            "list-units",
2385            "--type=service",
2386            "--all",
2387            "--no-pager",
2388            "--no-legend",
2389            "--plain",
2390        ])
2391        .output()
2392        .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
2393    if !status_output.status.success() {
2394        return Err("systemctl list-units returned a non-success status.".to_string());
2395    }
2396
2397    let startup_output = Command::new("systemctl")
2398        .args([
2399            "list-unit-files",
2400            "--type=service",
2401            "--no-legend",
2402            "--no-pager",
2403            "--plain",
2404        ])
2405        .output()
2406        .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
2407    if !startup_output.status.success() {
2408        return Err("systemctl list-unit-files returned a non-success status.".to_string());
2409    }
2410
2411    Ok(parse_unix_services(
2412        &String::from_utf8_lossy(&status_output.stdout),
2413        &String::from_utf8_lossy(&startup_output.stdout),
2414    ))
2415}
2416
2417#[cfg(target_os = "windows")]
2418fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2419    let output = Command::new("ipconfig")
2420        .args(["/all"])
2421        .output()
2422        .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
2423    if !output.status.success() {
2424        return Err("ipconfig returned a non-success status.".to_string());
2425    }
2426
2427    Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
2428        &output.stdout,
2429    )))
2430}
2431
2432#[cfg(not(target_os = "windows"))]
2433fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2434    let addr_output = Command::new("ip")
2435        .args(["-o", "addr", "show", "up"])
2436        .output()
2437        .map_err(|e| format!("Failed to run ip addr: {e}"))?;
2438    if !addr_output.status.success() {
2439        return Err("ip addr returned a non-success status.".to_string());
2440    }
2441
2442    let route_output = Command::new("ip")
2443        .args(["route", "show", "default"])
2444        .output()
2445        .map_err(|e| format!("Failed to run ip route: {e}"))?;
2446    if !route_output.status.success() {
2447        return Err("ip route returned a non-success status.".to_string());
2448    }
2449
2450    let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
2451    apply_unix_default_routes(
2452        &mut adapters,
2453        &String::from_utf8_lossy(&route_output.stdout),
2454    );
2455    apply_unix_dns_servers(&mut adapters);
2456    Ok(adapters)
2457}
2458
2459#[cfg(target_os = "windows")]
2460fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
2461    let output = Command::new("powershell")
2462        .args([
2463            "-NoProfile",
2464            "-Command",
2465            "Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount | ConvertTo-Json -Compress",
2466        ])
2467        .output()
2468        .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
2469
2470    if !output.status.success() {
2471        return Err("powershell Get-Process returned a non-success status.".to_string());
2472    }
2473
2474    let json_text = String::from_utf8_lossy(&output.stdout);
2475    let values: Value = serde_json::from_str(&json_text)
2476        .map_err(|e| format!("Failed to parse process JSON: {e}"))?;
2477
2478    let mut out = Vec::new();
2479    if let Some(arr) = values.as_array() {
2480        for v in arr {
2481            let name = v["Name"].as_str().unwrap_or("unknown").to_string();
2482            let pid = v["Id"].as_u64().unwrap_or(0) as u32;
2483            let memory_bytes = v["WorkingSet64"].as_u64().unwrap_or(0);
2484            let cpu_seconds = v["CPU"].as_f64();
2485            let read_ops = v["ReadOperationCount"].as_u64();
2486            let write_ops = v["WriteOperationCount"].as_u64();
2487            out.push(ProcessEntry {
2488                name,
2489                pid,
2490                memory_bytes,
2491                cpu_seconds,
2492                read_ops,
2493                write_ops,
2494                detail: None,
2495            });
2496        }
2497    } else if let Some(v) = values.as_object() {
2498        let name = v["Name"].as_str().unwrap_or("unknown").to_string();
2499        let pid = v["Id"].as_u64().unwrap_or(0) as u32;
2500        let memory_bytes = v["WorkingSet64"].as_u64().unwrap_or(0);
2501        let cpu_seconds = v["CPU"].as_f64();
2502        let read_ops = v["ReadOperationCount"].as_u64();
2503        let write_ops = v["WriteOperationCount"].as_u64();
2504        out.push(ProcessEntry {
2505            name,
2506            pid,
2507            memory_bytes,
2508            cpu_seconds,
2509            read_ops,
2510            write_ops,
2511            detail: None,
2512        });
2513    }
2514
2515    Ok(out)
2516}
2517
2518#[cfg(not(target_os = "windows"))]
2519fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
2520    let output = Command::new("ps")
2521        .args(["-eo", "pid=,rss=,comm="])
2522        .output()
2523        .map_err(|e| format!("Failed to run ps: {e}"))?;
2524    if !output.status.success() {
2525        return Err("ps returned a non-success status.".to_string());
2526    }
2527
2528    let text = String::from_utf8_lossy(&output.stdout);
2529    let mut processes = Vec::new();
2530    for line in text.lines() {
2531        let cols: Vec<&str> = line.split_whitespace().collect();
2532        if cols.len() < 3 {
2533            continue;
2534        }
2535        let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
2536        else {
2537            continue;
2538        };
2539        processes.push(ProcessEntry {
2540            name: cols[2..].join(" "),
2541            pid,
2542            memory_bytes: rss_kib * 1024,
2543            cpu_seconds: None,
2544            read_ops: None,
2545            write_ops: None,
2546            detail: None,
2547        });
2548    }
2549
2550    Ok(processes)
2551}
2552
2553fn extract_port_from_socket(value: &str) -> Option<u16> {
2554    let cleaned = value.trim().trim_matches(['[', ']']);
2555    let port_str = cleaned.rsplit(':').next()?;
2556    port_str.parse::<u16>().ok()
2557}
2558
2559fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
2560    let mut summary = ListenerExposureSummary::default();
2561    for entry in listeners {
2562        let local = entry.local.to_ascii_lowercase();
2563        if is_loopback_listener(&local) {
2564            summary.loopback_only += 1;
2565        } else if is_wildcard_listener(&local) {
2566            summary.wildcard_public += 1;
2567        } else {
2568            summary.specific_bind += 1;
2569        }
2570    }
2571    summary
2572}
2573
2574fn service_status_rank(status: &str) -> u8 {
2575    let lower = status.to_ascii_lowercase();
2576    if lower == "failed" || lower == "error" {
2577        0
2578    } else if lower == "running" || lower == "active" {
2579        1
2580    } else if lower == "starting" || lower == "activating" {
2581        2
2582    } else {
2583        3
2584    }
2585}
2586
2587fn is_loopback_listener(local: &str) -> bool {
2588    local.starts_with("127.")
2589        || local.starts_with("[::1]")
2590        || local.starts_with("::1")
2591        || local.starts_with("localhost:")
2592}
2593
2594fn is_wildcard_listener(local: &str) -> bool {
2595    local.starts_with("0.0.0.0:")
2596        || local.starts_with("[::]:")
2597        || local.starts_with(":::")
2598        || local == "*:*"
2599}
2600
2601struct GitState {
2602    root: PathBuf,
2603    branch: String,
2604    dirty_entries: usize,
2605}
2606
2607impl GitState {
2608    fn status_label(&self) -> String {
2609        if self.dirty_entries == 0 {
2610            "clean".to_string()
2611        } else {
2612            format!("dirty ({} changed path(s))", self.dirty_entries)
2613        }
2614    }
2615}
2616
2617fn inspect_git_state(path: &Path) -> Option<GitState> {
2618    let root = capture_first_line(
2619        "git",
2620        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
2621    )?;
2622    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
2623        .unwrap_or_else(|| "detached".to_string());
2624    let output = Command::new("git")
2625        .args(["-C", path.to_str()?, "status", "--short"])
2626        .output()
2627        .ok()?;
2628    if !output.status.success() {
2629        return None;
2630    }
2631    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
2632    Some(GitState {
2633        root: PathBuf::from(root),
2634        branch,
2635        dirty_entries,
2636    })
2637}
2638
2639struct HematiteState {
2640    docs_count: usize,
2641    import_count: usize,
2642    report_count: usize,
2643    workspace_profile: bool,
2644}
2645
2646fn collect_hematite_state(path: &Path) -> HematiteState {
2647    let root = path.join(".hematite");
2648    HematiteState {
2649        docs_count: count_entries_if_exists(&root.join("docs")),
2650        import_count: count_entries_if_exists(&root.join("imports")),
2651        report_count: count_entries_if_exists(&root.join("reports")),
2652        workspace_profile: root.join("workspace_profile.json").exists(),
2653    }
2654}
2655
2656fn count_entries_if_exists(path: &Path) -> usize {
2657    if !path.exists() || !path.is_dir() {
2658        return 0;
2659    }
2660    fs::read_dir(path)
2661        .ok()
2662        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
2663        .unwrap_or(0)
2664}
2665
2666fn collect_project_markers(path: &Path) -> Vec<String> {
2667    [
2668        "Cargo.toml",
2669        "package.json",
2670        "pyproject.toml",
2671        "go.mod",
2672        "justfile",
2673        "Makefile",
2674        ".git",
2675    ]
2676    .iter()
2677    .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
2678    .collect()
2679}
2680
2681struct ReleaseArtifactState {
2682    version: String,
2683    portable_dir: bool,
2684    portable_zip: bool,
2685    setup_exe: bool,
2686}
2687
2688fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
2689    let cargo_toml = path.join("Cargo.toml");
2690    if !cargo_toml.exists() {
2691        return None;
2692    }
2693    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
2694    let version = [regex_line_capture(
2695        &cargo_text,
2696        r#"(?m)^version\s*=\s*"([^"]+)""#,
2697    )?]
2698    .concat();
2699    let dist_windows = path.join("dist").join("windows");
2700    let prefix = format!("Hematite-{}", version);
2701    Some(ReleaseArtifactState {
2702        version,
2703        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
2704        portable_zip: dist_windows
2705            .join(format!("{}-portable.zip", prefix))
2706            .exists(),
2707        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
2708    })
2709}
2710
2711fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
2712    let regex = regex::Regex::new(pattern).ok()?;
2713    let captures = regex.captures(text)?;
2714    captures.get(1).map(|m| m.as_str().to_string())
2715}
2716
2717fn bool_label(value: bool) -> &'static str {
2718    if value {
2719        "yes"
2720    } else {
2721        "no"
2722    }
2723}
2724
2725fn collect_toolchains() -> ToolchainReport {
2726    let checks = [
2727        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
2728        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
2729        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
2730        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
2731        ToolCheck::new(
2732            "npm",
2733            &[
2734                CommandProbe::new("npm", &["--version"]),
2735                CommandProbe::new("npm.cmd", &["--version"]),
2736            ],
2737        ),
2738        ToolCheck::new(
2739            "pnpm",
2740            &[
2741                CommandProbe::new("pnpm", &["--version"]),
2742                CommandProbe::new("pnpm.cmd", &["--version"]),
2743            ],
2744        ),
2745        ToolCheck::new(
2746            "python",
2747            &[
2748                CommandProbe::new("python", &["--version"]),
2749                CommandProbe::new("python3", &["--version"]),
2750                CommandProbe::new("py", &["-3", "--version"]),
2751                CommandProbe::new("py", &["--version"]),
2752            ],
2753        ),
2754        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
2755        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
2756        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
2757        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
2758    ];
2759
2760    let mut found = Vec::new();
2761    let mut missing = Vec::new();
2762
2763    for check in checks {
2764        match check.detect() {
2765            Some(version) => found.push((check.label.to_string(), version)),
2766            None => missing.push(check.label.to_string()),
2767        }
2768    }
2769
2770    ToolchainReport { found, missing }
2771}
2772
2773fn collect_package_managers() -> PackageManagerReport {
2774    let checks = [
2775        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
2776        ToolCheck::new(
2777            "npm",
2778            &[
2779                CommandProbe::new("npm", &["--version"]),
2780                CommandProbe::new("npm.cmd", &["--version"]),
2781            ],
2782        ),
2783        ToolCheck::new(
2784            "pnpm",
2785            &[
2786                CommandProbe::new("pnpm", &["--version"]),
2787                CommandProbe::new("pnpm.cmd", &["--version"]),
2788            ],
2789        ),
2790        ToolCheck::new(
2791            "pip",
2792            &[
2793                CommandProbe::new("python", &["-m", "pip", "--version"]),
2794                CommandProbe::new("python3", &["-m", "pip", "--version"]),
2795                CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
2796                CommandProbe::new("py", &["-m", "pip", "--version"]),
2797                CommandProbe::new("pip", &["--version"]),
2798            ],
2799        ),
2800        ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
2801        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
2802        ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
2803        ToolCheck::new(
2804            "choco",
2805            &[
2806                CommandProbe::new("choco", &["--version"]),
2807                CommandProbe::new("choco.exe", &["--version"]),
2808            ],
2809        ),
2810        ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
2811    ];
2812
2813    let mut found = Vec::new();
2814    for check in checks {
2815        match check.detect() {
2816            Some(version) => found.push((check.label.to_string(), version)),
2817            None => {}
2818        }
2819    }
2820
2821    PackageManagerReport { found }
2822}
2823
2824#[derive(Clone)]
2825struct ToolCheck {
2826    label: &'static str,
2827    probes: Vec<CommandProbe>,
2828}
2829
2830impl ToolCheck {
2831    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
2832        Self {
2833            label,
2834            probes: probes.to_vec(),
2835        }
2836    }
2837
2838    fn detect(&self) -> Option<String> {
2839        for probe in &self.probes {
2840            if let Some(output) = capture_first_line(probe.program, probe.args) {
2841                return Some(output);
2842            }
2843        }
2844        None
2845    }
2846}
2847
2848#[derive(Clone, Copy)]
2849struct CommandProbe {
2850    program: &'static str,
2851    args: &'static [&'static str],
2852}
2853
2854impl CommandProbe {
2855    const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
2856        Self { program, args }
2857    }
2858}
2859
2860fn build_env_doctor_findings(
2861    toolchains: &ToolchainReport,
2862    package_managers: &PackageManagerReport,
2863    path_stats: &PathAnalysis,
2864) -> Vec<String> {
2865    let found_tools = toolchains
2866        .found
2867        .iter()
2868        .map(|(label, _)| label.as_str())
2869        .collect::<HashSet<_>>();
2870    let found_managers = package_managers
2871        .found
2872        .iter()
2873        .map(|(label, _)| label.as_str())
2874        .collect::<HashSet<_>>();
2875
2876    let mut findings = Vec::new();
2877
2878    if path_stats.duplicate_entries.len() > 0 {
2879        findings.push(format!(
2880            "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
2881            path_stats.duplicate_entries.len()
2882        ));
2883    }
2884    if path_stats.missing_entries.len() > 0 {
2885        findings.push(format!(
2886            "PATH contains {} entries that do not exist on disk.",
2887            path_stats.missing_entries.len()
2888        ));
2889    }
2890    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
2891        findings.push(
2892            "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
2893                .to_string(),
2894        );
2895    }
2896    if found_tools.contains("node")
2897        && !found_managers.contains("npm")
2898        && !found_managers.contains("pnpm")
2899    {
2900        findings.push(
2901            "Node is present but no JavaScript package manager was detected (npm or pnpm)."
2902                .to_string(),
2903        );
2904    }
2905    if found_tools.contains("python")
2906        && !found_managers.contains("pip")
2907        && !found_managers.contains("uv")
2908        && !found_managers.contains("pipx")
2909    {
2910        findings.push(
2911            "Python is present but no Python package manager was detected (pip, uv, or pipx)."
2912                .to_string(),
2913        );
2914    }
2915    let windows_manager_count = ["winget", "choco", "scoop"]
2916        .iter()
2917        .filter(|label| found_managers.contains(**label))
2918        .count();
2919    if windows_manager_count > 1 {
2920        findings.push(
2921            "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
2922                .to_string(),
2923        );
2924    }
2925    if findings.is_empty() && !found_managers.is_empty() {
2926        findings.push(
2927            "Core package-manager coverage looks healthy for a normal developer workstation."
2928                .to_string(),
2929        );
2930    }
2931
2932    findings
2933}
2934
2935fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
2936    let output = std::process::Command::new(program)
2937        .args(args)
2938        .output()
2939        .ok()?;
2940    if !output.status.success() {
2941        return None;
2942    }
2943
2944    let stdout = if output.stdout.is_empty() {
2945        String::from_utf8_lossy(&output.stderr).into_owned()
2946    } else {
2947        String::from_utf8_lossy(&output.stdout).into_owned()
2948    };
2949
2950    stdout
2951        .lines()
2952        .map(str::trim)
2953        .find(|line| !line.is_empty())
2954        .map(|line| line.to_string())
2955}
2956
2957fn human_bytes(bytes: u64) -> String {
2958    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
2959    let mut value = bytes as f64;
2960    let mut unit_index = 0usize;
2961
2962    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
2963        value /= 1024.0;
2964        unit_index += 1;
2965    }
2966
2967    if unit_index == 0 {
2968        format!("{} {}", bytes, UNITS[unit_index])
2969    } else {
2970        format!("{value:.1} {}", UNITS[unit_index])
2971    }
2972}
2973
2974#[cfg(target_os = "windows")]
2975fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
2976    let mut adapters = Vec::new();
2977    let mut current: Option<NetworkAdapter> = None;
2978    let mut pending_dns = false;
2979
2980    for raw_line in text.lines() {
2981        let line = raw_line.trim_end();
2982        let trimmed = line.trim();
2983        if trimmed.is_empty() {
2984            pending_dns = false;
2985            continue;
2986        }
2987
2988        if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
2989            if let Some(adapter) = current.take() {
2990                adapters.push(adapter);
2991            }
2992            current = Some(NetworkAdapter {
2993                name: trimmed.trim_end_matches(':').to_string(),
2994                ..NetworkAdapter::default()
2995            });
2996            pending_dns = false;
2997            continue;
2998        }
2999
3000        let Some(adapter) = current.as_mut() else {
3001            continue;
3002        };
3003
3004        if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3005            adapter.disconnected = true;
3006        }
3007
3008        if let Some(value) = value_after_colon(trimmed) {
3009            let normalized = normalize_ipconfig_value(value);
3010            if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3011                adapter.ipv4.push(normalized);
3012                pending_dns = false;
3013            } else if trimmed.starts_with("IPv6 Address")
3014                || trimmed.starts_with("Temporary IPv6 Address")
3015                || trimmed.starts_with("Link-local IPv6 Address")
3016            {
3017                if !normalized.is_empty() {
3018                    adapter.ipv6.push(normalized);
3019                }
3020                pending_dns = false;
3021            } else if trimmed.starts_with("Default Gateway") {
3022                if !normalized.is_empty() {
3023                    adapter.gateways.push(normalized);
3024                }
3025                pending_dns = false;
3026            } else if trimmed.starts_with("DNS Servers") {
3027                if !normalized.is_empty() {
3028                    adapter.dns_servers.push(normalized);
3029                }
3030                pending_dns = true;
3031            } else {
3032                pending_dns = false;
3033            }
3034        } else if pending_dns {
3035            let normalized = normalize_ipconfig_value(trimmed);
3036            if !normalized.is_empty() {
3037                adapter.dns_servers.push(normalized);
3038            }
3039        }
3040    }
3041
3042    if let Some(adapter) = current.take() {
3043        adapters.push(adapter);
3044    }
3045
3046    for adapter in &mut adapters {
3047        dedup_vec(&mut adapter.ipv4);
3048        dedup_vec(&mut adapter.ipv6);
3049        dedup_vec(&mut adapter.gateways);
3050        dedup_vec(&mut adapter.dns_servers);
3051    }
3052
3053    adapters
3054}
3055
3056#[cfg(not(target_os = "windows"))]
3057fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3058    let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3059
3060    for line in text.lines() {
3061        let cols: Vec<&str> = line.split_whitespace().collect();
3062        if cols.len() < 4 {
3063            continue;
3064        }
3065        let name = cols[1].trim_end_matches(':').to_string();
3066        let family = cols[2];
3067        let addr = cols[3].split('/').next().unwrap_or("").to_string();
3068        let entry = adapters
3069            .entry(name.clone())
3070            .or_insert_with(|| NetworkAdapter {
3071                name,
3072                ..NetworkAdapter::default()
3073            });
3074        match family {
3075            "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3076            "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3077            _ => {}
3078        }
3079    }
3080
3081    adapters.into_values().collect()
3082}
3083
3084#[cfg(not(target_os = "windows"))]
3085fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3086    for line in text.lines() {
3087        let cols: Vec<&str> = line.split_whitespace().collect();
3088        if cols.len() < 5 {
3089            continue;
3090        }
3091        let gateway = cols
3092            .windows(2)
3093            .find(|pair| pair[0] == "via")
3094            .map(|pair| pair[1].to_string());
3095        let dev = cols
3096            .windows(2)
3097            .find(|pair| pair[0] == "dev")
3098            .map(|pair| pair[1]);
3099        if let (Some(gateway), Some(dev)) = (gateway, dev) {
3100            if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3101                adapter.gateways.push(gateway);
3102            }
3103        }
3104    }
3105
3106    for adapter in adapters {
3107        dedup_vec(&mut adapter.gateways);
3108    }
3109}
3110
3111#[cfg(not(target_os = "windows"))]
3112fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3113    let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3114        return;
3115    };
3116    let mut dns_servers = text
3117        .lines()
3118        .filter_map(|line| line.strip_prefix("nameserver "))
3119        .map(str::trim)
3120        .filter(|value| !value.is_empty())
3121        .map(|value| value.to_string())
3122        .collect::<Vec<_>>();
3123    dedup_vec(&mut dns_servers);
3124    if dns_servers.is_empty() {
3125        return;
3126    }
3127    for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3128        adapter.dns_servers = dns_servers.clone();
3129    }
3130}
3131
3132#[cfg(target_os = "windows")]
3133fn value_after_colon(line: &str) -> Option<&str> {
3134    line.split_once(':').map(|(_, value)| value.trim())
3135}
3136
3137#[cfg(target_os = "windows")]
3138fn normalize_ipconfig_value(value: &str) -> String {
3139    value
3140        .trim()
3141        .trim_matches(['(', ')'])
3142        .trim_end_matches("(Preferred)")
3143        .trim()
3144        .to_string()
3145}
3146
3147fn dedup_vec(values: &mut Vec<String>) {
3148    let mut seen = HashSet::new();
3149    values.retain(|value| seen.insert(value.clone()));
3150}
3151
3152#[cfg(target_os = "windows")]
3153fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3154    let trimmed = text.trim();
3155    if trimmed.is_empty() {
3156        return Ok(Vec::new());
3157    }
3158
3159    let value: Value = serde_json::from_str(trimmed)
3160        .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3161    let entries = match value {
3162        Value::Array(items) => items,
3163        other => vec![other],
3164    };
3165
3166    let mut services = Vec::new();
3167    for entry in entries {
3168        let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3169            continue;
3170        };
3171        services.push(ServiceEntry {
3172            name: name.to_string(),
3173            status: entry
3174                .get("State")
3175                .and_then(|v| v.as_str())
3176                .unwrap_or("unknown")
3177                .to_string(),
3178            startup: entry
3179                .get("StartMode")
3180                .and_then(|v| v.as_str())
3181                .map(|value| value.to_string()),
3182            display_name: entry
3183                .get("DisplayName")
3184                .and_then(|v| v.as_str())
3185                .map(|value| value.to_string()),
3186        });
3187    }
3188
3189    Ok(services)
3190}
3191
3192#[cfg(not(target_os = "windows"))]
3193fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
3194    let mut startup_modes = std::collections::HashMap::<String, String>::new();
3195    for line in startup_text.lines() {
3196        let cols: Vec<&str> = line.split_whitespace().collect();
3197        if cols.len() < 2 {
3198            continue;
3199        }
3200        startup_modes.insert(cols[0].to_string(), cols[1].to_string());
3201    }
3202
3203    let mut services = Vec::new();
3204    for line in status_text.lines() {
3205        let cols: Vec<&str> = line.split_whitespace().collect();
3206        if cols.len() < 4 {
3207            continue;
3208        }
3209        let unit = cols[0];
3210        let load = cols[1];
3211        let active = cols[2];
3212        let sub = cols[3];
3213        let description = if cols.len() > 4 {
3214            Some(cols[4..].join(" "))
3215        } else {
3216            None
3217        };
3218        services.push(ServiceEntry {
3219            name: unit.to_string(),
3220            status: format!("{}/{}", active, sub),
3221            startup: startup_modes
3222                .get(unit)
3223                .cloned()
3224                .or_else(|| Some(load.to_string())),
3225            display_name: description,
3226        });
3227    }
3228
3229    services
3230}
3231
3232// ── health_report ─────────────────────────────────────────────────────────────
3233
3234/// Synthesized system health report — runs multiple checks and returns a
3235/// plain-English tiered verdict suitable for both developers and non-technical
3236/// users who just want to know if their machine is okay.
3237fn inspect_health_report() -> Result<String, String> {
3238    let mut needs_fix: Vec<String> = Vec::new();
3239    let mut watch: Vec<String> = Vec::new();
3240    let mut good: Vec<String> = Vec::new();
3241    let mut tips: Vec<String> = Vec::new();
3242
3243    health_check_disk(&mut needs_fix, &mut watch, &mut good);
3244    health_check_memory(&mut watch, &mut good);
3245    health_check_tools(&mut watch, &mut good, &mut tips);
3246    health_check_recent_errors(&mut watch, &mut tips);
3247
3248    let overall = if !needs_fix.is_empty() {
3249        "ACTION REQUIRED"
3250    } else if !watch.is_empty() {
3251        "WORTH A LOOK"
3252    } else {
3253        "ALL GOOD"
3254    };
3255
3256    let mut out = format!("System Health Report — {overall}\n\n");
3257
3258    if !needs_fix.is_empty() {
3259        out.push_str("Needs fixing:\n");
3260        for item in &needs_fix {
3261            out.push_str(&format!("  [!] {item}\n"));
3262        }
3263        out.push('\n');
3264    }
3265    if !watch.is_empty() {
3266        out.push_str("Worth watching:\n");
3267        for item in &watch {
3268            out.push_str(&format!("  [-] {item}\n"));
3269        }
3270        out.push('\n');
3271    }
3272    if !good.is_empty() {
3273        out.push_str("Looking good:\n");
3274        for item in &good {
3275            out.push_str(&format!("  [+] {item}\n"));
3276        }
3277        out.push('\n');
3278    }
3279    if !tips.is_empty() {
3280        out.push_str("To dig deeper:\n");
3281        for tip in &tips {
3282            out.push_str(&format!("  {tip}\n"));
3283        }
3284    }
3285
3286    Ok(out.trim_end().to_string())
3287}
3288
3289fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
3290    #[cfg(target_os = "windows")]
3291    {
3292        let script = r#"try {
3293    $d = Get-PSDrive C -ErrorAction Stop
3294    "$($d.Free)|$($d.Used)"
3295} catch { "ERR" }"#;
3296        if let Ok(out) = Command::new("powershell")
3297            .args(["-NoProfile", "-Command", script])
3298            .output()
3299        {
3300            let text = String::from_utf8_lossy(&out.stdout);
3301            let text = text.trim();
3302            if !text.starts_with("ERR") {
3303                let parts: Vec<&str> = text.split('|').collect();
3304                if parts.len() == 2 {
3305                    let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
3306                    let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
3307                    let total = free_bytes + used_bytes;
3308                    let free_gb = free_bytes / 1_073_741_824;
3309                    let pct_free = if total > 0 {
3310                        (free_bytes as f64 / total as f64 * 100.0) as u64
3311                    } else {
3312                        0
3313                    };
3314                    let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
3315                    if free_gb < 5 {
3316                        needs_fix.push(format!(
3317                            "{msg} — very low. Free up space or your system may slow down or stop working."
3318                        ));
3319                    } else if free_gb < 15 {
3320                        watch.push(format!("{msg} — getting low, consider cleaning up."));
3321                    } else {
3322                        good.push(msg);
3323                    }
3324                    return;
3325                }
3326            }
3327        }
3328        watch.push("Disk: could not read free space from C: drive.".to_string());
3329    }
3330
3331    #[cfg(not(target_os = "windows"))]
3332    {
3333        if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
3334            let text = String::from_utf8_lossy(&out.stdout);
3335            for line in text.lines().skip(1) {
3336                let cols: Vec<&str> = line.split_whitespace().collect();
3337                if cols.len() >= 5 {
3338                    let avail_str = cols[3].trim_end_matches('G');
3339                    let use_pct = cols[4].trim_end_matches('%');
3340                    let avail_gb: u64 = avail_str.parse().unwrap_or(0);
3341                    let used_pct: u64 = use_pct.parse().unwrap_or(0);
3342                    let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
3343                    if avail_gb < 5 {
3344                        needs_fix.push(format!(
3345                            "{msg} — very low. Free up space to prevent system issues."
3346                        ));
3347                    } else if avail_gb < 15 {
3348                        watch.push(format!("{msg} — getting low."));
3349                    } else {
3350                        good.push(msg);
3351                    }
3352                    return;
3353                }
3354            }
3355        }
3356        watch.push("Disk: could not determine free space.".to_string());
3357    }
3358}
3359
3360fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
3361    #[cfg(target_os = "windows")]
3362    {
3363        let script = r#"try {
3364    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
3365    "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
3366} catch { "ERR" }"#;
3367        if let Ok(out) = Command::new("powershell")
3368            .args(["-NoProfile", "-Command", script])
3369            .output()
3370        {
3371            let text = String::from_utf8_lossy(&out.stdout);
3372            let text = text.trim();
3373            if !text.starts_with("ERR") {
3374                let parts: Vec<&str> = text.split('|').collect();
3375                if parts.len() == 2 {
3376                    let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
3377                    let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
3378                    if total_kb > 0 {
3379                        let free_gb = free_kb / 1_048_576;
3380                        let total_gb = total_kb / 1_048_576;
3381                        let free_pct = free_kb * 100 / total_kb;
3382                        let msg = format!(
3383                            "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
3384                        );
3385                        if free_pct < 10 {
3386                            watch.push(format!(
3387                                "{msg} — very low. Close unused apps to free up memory."
3388                            ));
3389                        } else if free_pct < 25 {
3390                            watch.push(format!("{msg} — running a bit low."));
3391                        } else {
3392                            good.push(msg);
3393                        }
3394                        return;
3395                    }
3396                }
3397            }
3398        }
3399    }
3400
3401    #[cfg(not(target_os = "windows"))]
3402    {
3403        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
3404            let mut total_kb = 0u64;
3405            let mut avail_kb = 0u64;
3406            for line in content.lines() {
3407                if line.starts_with("MemTotal:") {
3408                    total_kb = line
3409                        .split_whitespace()
3410                        .nth(1)
3411                        .and_then(|v| v.parse().ok())
3412                        .unwrap_or(0);
3413                } else if line.starts_with("MemAvailable:") {
3414                    avail_kb = line
3415                        .split_whitespace()
3416                        .nth(1)
3417                        .and_then(|v| v.parse().ok())
3418                        .unwrap_or(0);
3419                }
3420            }
3421            if total_kb > 0 {
3422                let free_gb = avail_kb / 1_048_576;
3423                let total_gb = total_kb / 1_048_576;
3424                let free_pct = avail_kb * 100 / total_kb;
3425                let msg =
3426                    format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
3427                if free_pct < 10 {
3428                    watch.push(format!("{msg} — very low. Close unused apps."));
3429                } else if free_pct < 25 {
3430                    watch.push(format!("{msg} — running a bit low."));
3431                } else {
3432                    good.push(msg);
3433                }
3434            }
3435        }
3436    }
3437}
3438
3439fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
3440    let tool_checks: &[(&str, &str, &str)] = &[
3441        ("git", "--version", "Git"),
3442        ("cargo", "--version", "Rust / Cargo"),
3443        ("node", "--version", "Node.js"),
3444        ("python", "--version", "Python"),
3445        ("python3", "--version", "Python 3"),
3446        ("npm", "--version", "npm"),
3447    ];
3448
3449    let mut found: Vec<String> = Vec::new();
3450    let mut missing: Vec<String> = Vec::new();
3451    let mut python_found = false;
3452
3453    for (cmd, arg, label) in tool_checks {
3454        if cmd.starts_with("python") && python_found {
3455            continue;
3456        }
3457        let ok = Command::new(cmd)
3458            .arg(arg)
3459            .stdout(std::process::Stdio::null())
3460            .stderr(std::process::Stdio::null())
3461            .status()
3462            .map(|s| s.success())
3463            .unwrap_or(false);
3464        if ok {
3465            found.push((*label).to_string());
3466            if cmd.starts_with("python") {
3467                python_found = true;
3468            }
3469        } else if !cmd.starts_with("python") || !python_found {
3470            missing.push((*label).to_string());
3471        }
3472    }
3473
3474    if !found.is_empty() {
3475        good.push(format!("Dev tools found: {}", found.join(", ")));
3476    }
3477    if !missing.is_empty() {
3478        watch.push(format!(
3479            "Not installed (or not on PATH): {} — only matters if you need them",
3480            missing.join(", ")
3481        ));
3482        tips.push(
3483            "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
3484                .to_string(),
3485        );
3486    }
3487}
3488
3489fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
3490    #[cfg(target_os = "windows")]
3491    {
3492        let script = r#"try {
3493    $cutoff = (Get-Date).AddHours(-24)
3494    $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
3495    $count
3496} catch { "0" }"#;
3497        if let Ok(out) = Command::new("powershell")
3498            .args(["-NoProfile", "-Command", script])
3499            .output()
3500        {
3501            let text = String::from_utf8_lossy(&out.stdout);
3502            let count: u64 = text.trim().parse().unwrap_or(0);
3503            if count > 0 {
3504                watch.push(format!(
3505                    "{count} critical/error event{} in Windows event logs in the last 24 hours.",
3506                    if count == 1 { "" } else { "s" }
3507                ));
3508                tips.push(
3509                    "Run inspect_host(topic=\"log_check\") to see the actual error messages."
3510                        .to_string(),
3511                );
3512            }
3513        }
3514    }
3515
3516    #[cfg(not(target_os = "windows"))]
3517    {
3518        if let Ok(out) = Command::new("journalctl")
3519            .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
3520            .output()
3521        {
3522            let text = String::from_utf8_lossy(&out.stdout);
3523            if !text.trim().is_empty() {
3524                watch.push("Critical/error entries found in the system journal.".to_string());
3525                tips.push(
3526                    "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
3527                );
3528            }
3529        }
3530    }
3531}
3532
3533// ── log_check ─────────────────────────────────────────────────────────────────
3534
3535fn inspect_log_check(max_entries: usize) -> Result<String, String> {
3536    let mut out = String::from("Host inspection: log_check\n\n");
3537
3538    #[cfg(target_os = "windows")]
3539    {
3540        // Pull recent critical/error events from Windows Application and System logs.
3541        let n = max_entries.clamp(1, 50);
3542        let script = format!(
3543            r#"try {{
3544    $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3}} -MaxEvents 100 -ErrorAction SilentlyContinue
3545    if (-not $events) {{ "NO_EVENTS"; exit }}
3546    $events | Select-Object -First {n} | ForEach-Object {{
3547        $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
3548        $line
3549    }}
3550}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
3551            n = n
3552        );
3553        let output = Command::new("powershell")
3554            .args(["-NoProfile", "-Command", &script])
3555            .output()
3556            .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
3557
3558        let raw = String::from_utf8_lossy(&output.stdout);
3559        let text = raw.trim();
3560
3561        if text.is_empty() || text == "NO_EVENTS" {
3562            out.push_str("No critical or error events found in Application/System logs.\n");
3563            return Ok(out.trim_end().to_string());
3564        }
3565        if text.starts_with("ERROR:") {
3566            out.push_str(&format!("Warning: event log query returned: {text}\n"));
3567            return Ok(out.trim_end().to_string());
3568        }
3569
3570        let mut count = 0usize;
3571        for line in text.lines() {
3572            let parts: Vec<&str> = line.splitn(4, '|').collect();
3573            if parts.len() == 4 {
3574                let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
3575                out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
3576                count += 1;
3577            }
3578        }
3579        out.push_str(&format!(
3580            "\nEvents shown: {count} (critical/error from Application + System logs)\n"
3581        ));
3582    }
3583
3584    #[cfg(not(target_os = "windows"))]
3585    {
3586        // Use journalctl on Linux/macOS if available.
3587        let n = max_entries.clamp(1, 50).to_string();
3588        let output = Command::new("journalctl")
3589            .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
3590            .output();
3591
3592        match output {
3593            Ok(o) if o.status.success() => {
3594                let text = String::from_utf8_lossy(&o.stdout);
3595                let trimmed = text.trim();
3596                if trimmed.is_empty() || trimmed.contains("No entries") {
3597                    out.push_str("No critical or error entries found in the system journal.\n");
3598                } else {
3599                    out.push_str(trimmed);
3600                    out.push('\n');
3601                    out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
3602                }
3603            }
3604            _ => {
3605                // Fallback: check /var/log/syslog or /var/log/messages
3606                let log_paths = ["/var/log/syslog", "/var/log/messages"];
3607                let mut found = false;
3608                for log_path in &log_paths {
3609                    if let Ok(content) = std::fs::read_to_string(log_path) {
3610                        let lines: Vec<&str> = content.lines().collect();
3611                        let tail: Vec<&str> = lines
3612                            .iter()
3613                            .rev()
3614                            .filter(|l| {
3615                                let l_lower = l.to_ascii_lowercase();
3616                                l_lower.contains("error") || l_lower.contains("crit")
3617                            })
3618                            .take(max_entries)
3619                            .copied()
3620                            .collect::<Vec<_>>()
3621                            .into_iter()
3622                            .rev()
3623                            .collect();
3624                        if !tail.is_empty() {
3625                            out.push_str(&format!("Source: {log_path}\n"));
3626                            for l in &tail {
3627                                out.push_str(l);
3628                                out.push('\n');
3629                            }
3630                            found = true;
3631                            break;
3632                        }
3633                    }
3634                }
3635                if !found {
3636                    out.push_str(
3637                        "journalctl not found and no readable syslog detected on this system.\n",
3638                    );
3639                }
3640            }
3641        }
3642    }
3643
3644    Ok(out.trim_end().to_string())
3645}
3646
3647// ── startup_items ─────────────────────────────────────────────────────────────
3648
3649fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
3650    let mut out = String::from("Host inspection: startup_items\n\n");
3651
3652    #[cfg(target_os = "windows")]
3653    {
3654        // Query both HKLM and HKCU Run keys.
3655        let script = r#"
3656$hives = @(
3657    @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
3658    @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
3659    @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
3660)
3661foreach ($h in $hives) {
3662    try {
3663        $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
3664        $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
3665            "$($h.Hive)|$($_.Name)|$($_.Value)"
3666        }
3667    } catch {}
3668}
3669"#;
3670        let output = Command::new("powershell")
3671            .args(["-NoProfile", "-Command", script])
3672            .output()
3673            .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
3674
3675        let raw = String::from_utf8_lossy(&output.stdout);
3676        let text = raw.trim();
3677
3678        let entries: Vec<(String, String, String)> = text
3679            .lines()
3680            .filter_map(|l| {
3681                let parts: Vec<&str> = l.splitn(3, '|').collect();
3682                if parts.len() == 3 {
3683                    Some((
3684                        parts[0].to_string(),
3685                        parts[1].to_string(),
3686                        parts[2].to_string(),
3687                    ))
3688                } else {
3689                    None
3690                }
3691            })
3692            .take(max_entries)
3693            .collect();
3694
3695        if entries.is_empty() {
3696            out.push_str("No startup entries found in the Windows Run registry keys.\n");
3697        } else {
3698            out.push_str("Registry run keys (programs that start with Windows):\n\n");
3699            let mut last_hive = String::new();
3700            for (hive, name, value) in &entries {
3701                if *hive != last_hive {
3702                    out.push_str(&format!("[{}]\n", hive));
3703                    last_hive = hive.clone();
3704                }
3705                // Truncate very long values (paths with many args)
3706                let display = if value.len() > 100 {
3707                    format!("{}…", &value[..100])
3708                } else {
3709                    value.clone()
3710                };
3711                out.push_str(&format!("  {name}: {display}\n"));
3712            }
3713            out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
3714        }
3715
3716        // 3. Unified Startup Command check (Task Manager style)
3717        let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { "  $($_.Name): $($_.Command) ($($_.Location))" }"#;
3718        if let Ok(unified_out) = Command::new("powershell")
3719            .args(["-NoProfile", "-Command", unified_script])
3720            .output()
3721        {
3722            let unified_text = String::from_utf8_lossy(&unified_out.stdout);
3723            let trimmed = unified_text.trim();
3724            if !trimmed.is_empty() {
3725                out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
3726                out.push_str(trimmed);
3727                out.push('\n');
3728            }
3729        }
3730    }
3731
3732    #[cfg(not(target_os = "windows"))]
3733    {
3734        // On Linux: systemd enabled services + cron @reboot entries.
3735        let output = Command::new("systemctl")
3736            .args([
3737                "list-unit-files",
3738                "--type=service",
3739                "--state=enabled",
3740                "--no-legend",
3741                "--no-pager",
3742                "--plain",
3743            ])
3744            .output();
3745
3746        match output {
3747            Ok(o) if o.status.success() => {
3748                let text = String::from_utf8_lossy(&o.stdout);
3749                let services: Vec<&str> = text
3750                    .lines()
3751                    .filter(|l| !l.trim().is_empty())
3752                    .take(max_entries)
3753                    .collect();
3754                if services.is_empty() {
3755                    out.push_str("No enabled systemd services found.\n");
3756                } else {
3757                    out.push_str("Enabled systemd services (run at boot):\n\n");
3758                    for s in &services {
3759                        out.push_str(&format!("  {s}\n"));
3760                    }
3761                    out.push_str(&format!(
3762                        "\nShowing {} of enabled services.\n",
3763                        services.len()
3764                    ));
3765                }
3766            }
3767            _ => {
3768                out.push_str(
3769                    "systemctl not found on this system. Cannot enumerate startup services.\n",
3770                );
3771            }
3772        }
3773
3774        // Check @reboot cron entries.
3775        if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
3776            let cron_text = String::from_utf8_lossy(&cron_out.stdout);
3777            let reboot_entries: Vec<&str> = cron_text
3778                .lines()
3779                .filter(|l| l.trim_start().starts_with("@reboot"))
3780                .collect();
3781            if !reboot_entries.is_empty() {
3782                out.push_str("\nCron @reboot entries:\n");
3783                for e in reboot_entries {
3784                    out.push_str(&format!("  {e}\n"));
3785                }
3786            }
3787        }
3788    }
3789
3790    Ok(out.trim_end().to_string())
3791}
3792
3793fn inspect_os_config() -> Result<String, String> {
3794    let mut out = String::from("Host inspection: OS Configuration\n\n");
3795
3796    #[cfg(target_os = "windows")]
3797    {
3798        // Power Plan
3799        if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
3800            let power_str = String::from_utf8_lossy(&power_out.stdout);
3801            out.push_str("=== Power Plan ===\n");
3802            out.push_str(power_str.trim());
3803            out.push_str("\n\n");
3804        }
3805
3806        // Firewall Status
3807        let fw_script =
3808            "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
3809        if let Ok(fw_out) = Command::new("powershell")
3810            .args(["-NoProfile", "-Command", fw_script])
3811            .output()
3812        {
3813            let fw_str = String::from_utf8_lossy(&fw_out.stdout);
3814            out.push_str("=== Firewall Profiles ===\n");
3815            out.push_str(fw_str.trim());
3816            out.push_str("\n\n");
3817        }
3818
3819        // System Uptime
3820        let uptime_script =
3821            "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
3822        if let Ok(uptime_out) = Command::new("powershell")
3823            .args(["-NoProfile", "-Command", uptime_script])
3824            .output()
3825        {
3826            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
3827            out.push_str("=== System Uptime (Last Boot) ===\n");
3828            out.push_str(uptime_str.trim());
3829            out.push_str("\n\n");
3830        }
3831    }
3832
3833    #[cfg(not(target_os = "windows"))]
3834    {
3835        // Uptime
3836        if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
3837            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
3838            out.push_str("=== System Uptime ===\n");
3839            out.push_str(uptime_str.trim());
3840            out.push_str("\n\n");
3841        }
3842
3843        // Firewall (ufw status if available)
3844        if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
3845            let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
3846            if !ufw_str.trim().is_empty() {
3847                out.push_str("=== Firewall (UFW) ===\n");
3848                out.push_str(ufw_str.trim());
3849                out.push_str("\n\n");
3850            }
3851        }
3852    }
3853    Ok(out.trim_end().to_string())
3854}
3855
3856pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
3857    let action = args
3858        .get("action")
3859        .and_then(|v| v.as_str())
3860        .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
3861
3862    let target = args
3863        .get("target")
3864        .and_then(|v| v.as_str())
3865        .unwrap_or("")
3866        .trim();
3867
3868    if target.is_empty() && action != "clear_temp" {
3869        return Err("Missing required argument: 'target' for this action".to_string());
3870    }
3871
3872    match action {
3873        "install_package" => {
3874            #[cfg(target_os = "windows")]
3875            {
3876                let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
3877                match Command::new("powershell")
3878                    .args(["-NoProfile", "-Command", &cmd])
3879                    .output()
3880                {
3881                    Ok(out) => Ok(format!(
3882                        "Executed remediation (winget install):\n{}",
3883                        String::from_utf8_lossy(&out.stdout)
3884                    )),
3885                    Err(e) => Err(format!("Failed to run winget: {}", e)),
3886                }
3887            }
3888            #[cfg(not(target_os = "windows"))]
3889            {
3890                Err(
3891                    "install_package via wrapper is only supported on Windows currently (winget)"
3892                        .to_string(),
3893                )
3894            }
3895        }
3896        "restart_service" => {
3897            #[cfg(target_os = "windows")]
3898            {
3899                let cmd = format!("Restart-Service -Name {} -Force", target);
3900                match Command::new("powershell")
3901                    .args(["-NoProfile", "-Command", &cmd])
3902                    .output()
3903                {
3904                    Ok(out) => {
3905                        let err_str = String::from_utf8_lossy(&out.stderr);
3906                        if !err_str.is_empty() {
3907                            return Err(format!("Error restarting service:\n{}", err_str));
3908                        }
3909                        Ok(format!("Successfully restarted service: {}", target))
3910                    }
3911                    Err(e) => Err(format!("Failed to restart service: {}", e)),
3912                }
3913            }
3914            #[cfg(not(target_os = "windows"))]
3915            {
3916                Err(
3917                    "restart_service via wrapper is only supported on Windows currently"
3918                        .to_string(),
3919                )
3920            }
3921        }
3922        "clear_temp" => {
3923            #[cfg(target_os = "windows")]
3924            {
3925                let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
3926                match Command::new("powershell")
3927                    .args(["-NoProfile", "-Command", cmd])
3928                    .output()
3929                {
3930                    Ok(_) => Ok("Successfully cleared temporary files".to_string()),
3931                    Err(e) => Err(format!("Failed to clear temp: {}", e)),
3932                }
3933            }
3934            #[cfg(not(target_os = "windows"))]
3935            {
3936                Err("clear_temp via wrapper is only supported on Windows currently".to_string())
3937            }
3938        }
3939        other => Err(format!("Unknown remediation action: {}", other)),
3940    }
3941}
3942
3943// ── storage ───────────────────────────────────────────────────────────────────
3944
3945fn inspect_storage(max_entries: usize) -> Result<String, String> {
3946    let mut out = String::from("Host inspection: storage\n\n");
3947    let _ = max_entries; // used by non-Windows branch
3948
3949    // ── Drive overview ────────────────────────────────────────────────────────
3950    out.push_str("Drives:\n");
3951
3952    #[cfg(target_os = "windows")]
3953    {
3954        let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
3955    $free = $_.Free
3956    $used = $_.Used
3957    if ($free -eq $null) { $free = 0 }
3958    if ($used -eq $null) { $used = 0 }
3959    $total = $free + $used
3960    "$($_.Name)|$free|$used|$total"
3961}"#;
3962        match Command::new("powershell")
3963            .args(["-NoProfile", "-Command", script])
3964            .output()
3965        {
3966            Ok(o) => {
3967                let text = String::from_utf8_lossy(&o.stdout);
3968                let mut drive_count = 0usize;
3969                for line in text.lines() {
3970                    let parts: Vec<&str> = line.trim().split('|').collect();
3971                    if parts.len() == 4 {
3972                        let name = parts[0];
3973                        let free: u64 = parts[1].parse().unwrap_or(0);
3974                        let total: u64 = parts[3].parse().unwrap_or(0);
3975                        if total == 0 {
3976                            continue;
3977                        }
3978                        let free_gb = free / 1_073_741_824;
3979                        let total_gb = total / 1_073_741_824;
3980                        let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
3981                        let bar_len = 20usize;
3982                        let filled = (used_pct as usize * bar_len / 100).min(bar_len);
3983                        let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
3984                        let warn = if free_gb < 5 {
3985                            " [!] CRITICALLY LOW"
3986                        } else if free_gb < 15 {
3987                            " [-] LOW"
3988                        } else {
3989                            ""
3990                        };
3991                        out.push_str(&format!(
3992                            "  {name}:  [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
3993                        ));
3994                        drive_count += 1;
3995                    }
3996                }
3997                if drive_count == 0 {
3998                    out.push_str("  (could not enumerate drives)\n");
3999                }
4000            }
4001            Err(e) => out.push_str(&format!("  (drive scan failed: {e})\n")),
4002        }
4003
4004        // ── Real-time Performance (Latency) ──────────────────────────────────
4005        let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
4006        match Command::new("powershell")
4007            .args(["-NoProfile", "-Command", latency_script])
4008            .output()
4009        {
4010            Ok(o) => {
4011                let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
4012                if !text.is_empty() {
4013                    out.push_str("\nReal-time Disk Intensity:\n");
4014                    out.push_str(&format!("  Average Disk Queue Length: {text}\n"));
4015                    if let Ok(q) = text.parse::<f64>() {
4016                        if q > 2.0 {
4017                            out.push_str(
4018                                "  [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
4019                            );
4020                        } else {
4021                            out.push_str("  [~] Disk latency is within healthy bounds.\n");
4022                        }
4023                    }
4024                }
4025            }
4026            Err(_) => {}
4027        }
4028    }
4029
4030    #[cfg(not(target_os = "windows"))]
4031    {
4032        match Command::new("df")
4033            .args(["-h", "--output=target,size,avail,pcent"])
4034            .output()
4035        {
4036            Ok(o) => {
4037                let text = String::from_utf8_lossy(&o.stdout);
4038                let mut count = 0usize;
4039                for line in text.lines().skip(1) {
4040                    let cols: Vec<&str> = line.split_whitespace().collect();
4041                    if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
4042                        out.push_str(&format!(
4043                            "  {}  size: {}  avail: {}  used: {}\n",
4044                            cols[0], cols[1], cols[2], cols[3]
4045                        ));
4046                        count += 1;
4047                        if count >= max_entries {
4048                            break;
4049                        }
4050                    }
4051                }
4052            }
4053            Err(e) => out.push_str(&format!("  (df failed: {e})\n")),
4054        }
4055    }
4056
4057    // ── Large developer cache directories ─────────────────────────────────────
4058    out.push_str("\nLarge developer cache directories (if present):\n");
4059
4060    #[cfg(target_os = "windows")]
4061    {
4062        let home = std::env::var("USERPROFILE").unwrap_or_default();
4063        let check_dirs: &[(&str, &str)] = &[
4064            ("Temp", r"AppData\Local\Temp"),
4065            ("npm cache", r"AppData\Roaming\npm-cache"),
4066            ("Cargo registry", r".cargo\registry"),
4067            ("Cargo git", r".cargo\git"),
4068            ("pip cache", r"AppData\Local\pip\cache"),
4069            ("Yarn cache", r"AppData\Local\Yarn\Cache"),
4070            (".rustup toolchains", r".rustup\toolchains"),
4071            ("node_modules (home)", r"node_modules"),
4072        ];
4073
4074        let mut found_any = false;
4075        for (label, rel) in check_dirs {
4076            let full = format!(r"{}\{}", home, rel);
4077            let path = std::path::Path::new(&full);
4078            if path.exists() {
4079                // Quick size estimate via PowerShell (non-blocking cap at 5s)
4080                let size_script = format!(
4081                    r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
4082                    full.replace('\'', "''")
4083                );
4084                let size_mb = Command::new("powershell")
4085                    .args(["-NoProfile", "-Command", &size_script])
4086                    .output()
4087                    .ok()
4088                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
4089                    .unwrap_or_else(|| "?".to_string());
4090                out.push_str(&format!("  {label}: {size_mb} MB  ({full})\n"));
4091                found_any = true;
4092            }
4093        }
4094        if !found_any {
4095            out.push_str("  (none of the common cache directories found)\n");
4096        }
4097
4098        out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
4099    }
4100
4101    #[cfg(not(target_os = "windows"))]
4102    {
4103        let home = std::env::var("HOME").unwrap_or_default();
4104        let check_dirs: &[(&str, &str)] = &[
4105            ("npm cache", ".npm"),
4106            ("Cargo registry", ".cargo/registry"),
4107            ("pip cache", ".cache/pip"),
4108            (".rustup toolchains", ".rustup/toolchains"),
4109            ("Yarn cache", ".cache/yarn"),
4110        ];
4111        let mut found_any = false;
4112        for (label, rel) in check_dirs {
4113            let full = format!("{}/{}", home, rel);
4114            if std::path::Path::new(&full).exists() {
4115                let size = Command::new("du")
4116                    .args(["-sh", &full])
4117                    .output()
4118                    .ok()
4119                    .map(|o| {
4120                        let s = String::from_utf8_lossy(&o.stdout);
4121                        s.split_whitespace().next().unwrap_or("?").to_string()
4122                    })
4123                    .unwrap_or_else(|| "?".to_string());
4124                out.push_str(&format!("  {label}: {size}  ({full})\n"));
4125                found_any = true;
4126            }
4127        }
4128        if !found_any {
4129            out.push_str("  (none of the common cache directories found)\n");
4130        }
4131    }
4132
4133    Ok(out.trim_end().to_string())
4134}
4135
4136// ── hardware ──────────────────────────────────────────────────────────────────
4137
4138fn inspect_hardware() -> Result<String, String> {
4139    let mut out = String::from("Host inspection: hardware\n\n");
4140
4141    #[cfg(target_os = "windows")]
4142    {
4143        // CPU
4144        let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
4145    "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
4146} | Select-Object -First 1"#;
4147        if let Ok(o) = Command::new("powershell")
4148            .args(["-NoProfile", "-Command", cpu_script])
4149            .output()
4150        {
4151            let text = String::from_utf8_lossy(&o.stdout);
4152            let text = text.trim();
4153            let parts: Vec<&str> = text.split('|').collect();
4154            if parts.len() == 4 {
4155                out.push_str(&format!(
4156                    "CPU: {}\n  {} physical cores, {} logical processors, {:.1} GHz\n\n",
4157                    parts[0],
4158                    parts[1],
4159                    parts[2],
4160                    parts[3].parse::<f32>().unwrap_or(0.0)
4161                ));
4162            } else {
4163                out.push_str(&format!("CPU: {text}\n\n"));
4164            }
4165        }
4166
4167        // RAM (total installed + speed)
4168        let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
4169$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
4170$speed = ($sticks | Select-Object -First 1).Speed
4171"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
4172        if let Ok(o) = Command::new("powershell")
4173            .args(["-NoProfile", "-Command", ram_script])
4174            .output()
4175        {
4176            let text = String::from_utf8_lossy(&o.stdout);
4177            out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
4178        }
4179
4180        // GPU(s)
4181        let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
4182    "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
4183}"#;
4184        if let Ok(o) = Command::new("powershell")
4185            .args(["-NoProfile", "-Command", gpu_script])
4186            .output()
4187        {
4188            let text = String::from_utf8_lossy(&o.stdout);
4189            let lines: Vec<&str> = text.lines().collect();
4190            if !lines.is_empty() {
4191                out.push_str("GPU(s):\n");
4192                for line in lines.iter().filter(|l| !l.trim().is_empty()) {
4193                    let parts: Vec<&str> = line.trim().split('|').collect();
4194                    if parts.len() == 3 {
4195                        let res = if parts[2] == "x" || parts[2].starts_with('0') {
4196                            String::new()
4197                        } else {
4198                            format!(" — {}@display", parts[2])
4199                        };
4200                        out.push_str(&format!(
4201                            "  {}\n    Driver: {}{}\n",
4202                            parts[0], parts[1], res
4203                        ));
4204                    } else {
4205                        out.push_str(&format!("  {}\n", line.trim()));
4206                    }
4207                }
4208                out.push('\n');
4209            }
4210        }
4211
4212        // Motherboard + BIOS + Virtualization
4213        let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
4214$bios = Get-CimInstance Win32_BIOS
4215$cs = Get-CimInstance Win32_ComputerSystem
4216$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
4217$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
4218"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
4219        if let Ok(o) = Command::new("powershell")
4220            .args(["-NoProfile", "-Command", mb_script])
4221            .output()
4222        {
4223            let text = String::from_utf8_lossy(&o.stdout);
4224            let text = text.trim().trim_matches('"');
4225            let parts: Vec<&str> = text.split('|').collect();
4226            if parts.len() == 4 {
4227                out.push_str(&format!(
4228                    "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
4229                    parts[0].trim(),
4230                    parts[1].trim(),
4231                    parts[2].trim(),
4232                    parts[3].trim()
4233                ));
4234            }
4235        }
4236
4237        // Display(s)
4238        let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
4239    "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
4240}"#;
4241        if let Ok(o) = Command::new("powershell")
4242            .args(["-NoProfile", "-Command", disp_script])
4243            .output()
4244        {
4245            let text = String::from_utf8_lossy(&o.stdout);
4246            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4247            if !lines.is_empty() {
4248                out.push_str("Display(s):\n");
4249                for line in &lines {
4250                    let parts: Vec<&str> = line.trim().split('|').collect();
4251                    if parts.len() == 2 {
4252                        out.push_str(&format!("  {} — {}\n", parts[0].trim(), parts[1]));
4253                    }
4254                }
4255            }
4256        }
4257    }
4258
4259    #[cfg(not(target_os = "windows"))]
4260    {
4261        // CPU via /proc/cpuinfo
4262        if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
4263            let model = content
4264                .lines()
4265                .find(|l| l.starts_with("model name"))
4266                .and_then(|l| l.split(':').nth(1))
4267                .map(str::trim)
4268                .unwrap_or("unknown");
4269            let cores = content
4270                .lines()
4271                .filter(|l| l.starts_with("processor"))
4272                .count();
4273            out.push_str(&format!("CPU: {model}\n  {cores} logical processors\n\n"));
4274        }
4275
4276        // RAM
4277        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4278            let total_kb: u64 = content
4279                .lines()
4280                .find(|l| l.starts_with("MemTotal:"))
4281                .and_then(|l| l.split_whitespace().nth(1))
4282                .and_then(|v| v.parse().ok())
4283                .unwrap_or(0);
4284            let total_gb = total_kb / 1_048_576;
4285            out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
4286        }
4287
4288        // GPU via lspci
4289        if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
4290            let text = String::from_utf8_lossy(&o.stdout);
4291            let gpu_lines: Vec<&str> = text
4292                .lines()
4293                .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
4294                .collect();
4295            if !gpu_lines.is_empty() {
4296                out.push_str("GPU(s):\n");
4297                for l in gpu_lines {
4298                    out.push_str(&format!("  {l}\n"));
4299                }
4300                out.push('\n');
4301            }
4302        }
4303
4304        // DMI/BIOS info
4305        if let Ok(o) = Command::new("dmidecode")
4306            .args(["-t", "baseboard", "-t", "bios"])
4307            .output()
4308        {
4309            let text = String::from_utf8_lossy(&o.stdout);
4310            out.push_str("Motherboard/BIOS:\n");
4311            for line in text
4312                .lines()
4313                .filter(|l| {
4314                    l.contains("Manufacturer:")
4315                        || l.contains("Product Name:")
4316                        || l.contains("Version:")
4317                })
4318                .take(6)
4319            {
4320                out.push_str(&format!("  {}\n", line.trim()));
4321            }
4322        }
4323    }
4324
4325    Ok(out.trim_end().to_string())
4326}
4327
4328// ── updates ───────────────────────────────────────────────────────────────────
4329
4330fn inspect_updates() -> Result<String, String> {
4331    let mut out = String::from("Host inspection: updates\n\n");
4332
4333    #[cfg(target_os = "windows")]
4334    {
4335        // Last installed update via COM
4336        let script = r#"
4337try {
4338    $sess = New-Object -ComObject Microsoft.Update.Session
4339    $searcher = $sess.CreateUpdateSearcher()
4340    $count = $searcher.GetTotalHistoryCount()
4341    if ($count -gt 0) {
4342        $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
4343        $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
4344    } else { "NONE|LAST_INSTALL" }
4345} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
4346"#;
4347        if let Ok(o) = Command::new("powershell")
4348            .args(["-NoProfile", "-Command", script])
4349            .output()
4350        {
4351            let raw = String::from_utf8_lossy(&o.stdout);
4352            let text = raw.trim();
4353            if text.starts_with("ERROR:") {
4354                out.push_str("Last update install: (unable to query)\n");
4355            } else if text.contains("NONE") {
4356                out.push_str("Last update install: No update history found\n");
4357            } else {
4358                let date = text.replace("|LAST_INSTALL", "");
4359                out.push_str(&format!("Last update install: {date}\n"));
4360            }
4361        }
4362
4363        // Pending updates count
4364        let pending_script = r#"
4365try {
4366    $sess = New-Object -ComObject Microsoft.Update.Session
4367    $searcher = $sess.CreateUpdateSearcher()
4368    $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
4369    $results.Updates.Count.ToString() + "|PENDING"
4370} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
4371"#;
4372        if let Ok(o) = Command::new("powershell")
4373            .args(["-NoProfile", "-Command", pending_script])
4374            .output()
4375        {
4376            let raw = String::from_utf8_lossy(&o.stdout);
4377            let text = raw.trim();
4378            if text.starts_with("ERROR:") {
4379                out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
4380            } else {
4381                let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
4382                if count == 0 {
4383                    out.push_str("Pending updates: Up to date — no updates waiting\n");
4384                } else if count > 0 {
4385                    out.push_str(&format!("Pending updates: {count} update(s) available\n"));
4386                    out.push_str(
4387                        "  → Open Windows Update (Settings > Windows Update) to install\n",
4388                    );
4389                }
4390            }
4391        }
4392
4393        // Windows Update service state
4394        let svc_script = r#"
4395$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
4396if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
4397"#;
4398        if let Ok(o) = Command::new("powershell")
4399            .args(["-NoProfile", "-Command", svc_script])
4400            .output()
4401        {
4402            let raw = String::from_utf8_lossy(&o.stdout);
4403            let status = raw.trim();
4404            out.push_str(&format!("Windows Update service: {status}\n"));
4405        }
4406    }
4407
4408    #[cfg(not(target_os = "windows"))]
4409    {
4410        let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
4411        let mut found = false;
4412        if let Ok(o) = apt_out {
4413            let text = String::from_utf8_lossy(&o.stdout);
4414            let lines: Vec<&str> = text
4415                .lines()
4416                .filter(|l| l.contains('/') && !l.contains("Listing"))
4417                .collect();
4418            if !lines.is_empty() {
4419                out.push_str(&format!(
4420                    "{} package(s) can be upgraded (apt)\n",
4421                    lines.len()
4422                ));
4423                out.push_str("  → Run: sudo apt upgrade\n");
4424                found = true;
4425            }
4426        }
4427        if !found {
4428            if let Ok(o) = Command::new("dnf")
4429                .args(["check-update", "--quiet"])
4430                .output()
4431            {
4432                let text = String::from_utf8_lossy(&o.stdout);
4433                let count = text
4434                    .lines()
4435                    .filter(|l| !l.is_empty() && !l.starts_with('!'))
4436                    .count();
4437                if count > 0 {
4438                    out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
4439                    out.push_str("  → Run: sudo dnf upgrade\n");
4440                } else {
4441                    out.push_str("System is up to date.\n");
4442                }
4443            } else {
4444                out.push_str("Could not query package manager for updates.\n");
4445            }
4446        }
4447    }
4448
4449    Ok(out.trim_end().to_string())
4450}
4451
4452// ── security ──────────────────────────────────────────────────────────────────
4453
4454fn inspect_security() -> Result<String, String> {
4455    let mut out = String::from("Host inspection: security\n\n");
4456
4457    #[cfg(target_os = "windows")]
4458    {
4459        // Windows Defender status
4460        let defender_script = r#"
4461try {
4462    $status = Get-MpComputerStatus -ErrorAction Stop
4463    "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
4464} catch { "ERROR:" + $_.Exception.Message }
4465"#;
4466        if let Ok(o) = Command::new("powershell")
4467            .args(["-NoProfile", "-Command", defender_script])
4468            .output()
4469        {
4470            let raw = String::from_utf8_lossy(&o.stdout);
4471            let text = raw.trim();
4472            if text.starts_with("ERROR:") {
4473                out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
4474            } else {
4475                let get = |key: &str| -> String {
4476                    text.split('|')
4477                        .find(|s| s.starts_with(key))
4478                        .and_then(|s| s.splitn(2, ':').nth(1))
4479                        .unwrap_or("unknown")
4480                        .to_string()
4481                };
4482                let rtp = get("RTP");
4483                let last_scan = {
4484                    // SCAN field has a colon in the time, so grab everything after "SCAN:"
4485                    text.split('|')
4486                        .find(|s| s.starts_with("SCAN:"))
4487                        .and_then(|s| s.get(5..))
4488                        .unwrap_or("unknown")
4489                        .to_string()
4490                };
4491                let def_ver = get("VER");
4492                let age_days: i64 = get("AGE").parse().unwrap_or(-1);
4493
4494                let rtp_label = if rtp == "True" {
4495                    "ENABLED"
4496                } else {
4497                    "DISABLED [!]"
4498                };
4499                out.push_str(&format!(
4500                    "Windows Defender real-time protection: {rtp_label}\n"
4501                ));
4502                out.push_str(&format!("Last quick scan: {last_scan}\n"));
4503                out.push_str(&format!("Signature version: {def_ver}\n"));
4504                if age_days >= 0 {
4505                    let freshness = if age_days == 0 {
4506                        "up to date".to_string()
4507                    } else if age_days <= 3 {
4508                        format!("{age_days} day(s) old — OK")
4509                    } else if age_days <= 7 {
4510                        format!("{age_days} day(s) old — consider updating")
4511                    } else {
4512                        format!("{age_days} day(s) old — [!] STALE, run Windows Update")
4513                    };
4514                    out.push_str(&format!("Signature age: {freshness}\n"));
4515                }
4516                if rtp != "True" {
4517                    out.push_str(
4518                        "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
4519                    );
4520                    out.push_str(
4521                        "    → Open Windows Security > Virus & threat protection to re-enable.\n",
4522                    );
4523                }
4524            }
4525        }
4526
4527        out.push('\n');
4528
4529        // Windows Firewall state
4530        let fw_script = r#"
4531try {
4532    Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
4533} catch { "ERROR:" + $_.Exception.Message }
4534"#;
4535        if let Ok(o) = Command::new("powershell")
4536            .args(["-NoProfile", "-Command", fw_script])
4537            .output()
4538        {
4539            let raw = String::from_utf8_lossy(&o.stdout);
4540            let text = raw.trim();
4541            if !text.starts_with("ERROR:") && !text.is_empty() {
4542                out.push_str("Windows Firewall:\n");
4543                for line in text.lines() {
4544                    if let Some((name, enabled)) = line.split_once(':') {
4545                        let state = if enabled.trim() == "True" {
4546                            "ON"
4547                        } else {
4548                            "OFF [!]"
4549                        };
4550                        out.push_str(&format!("  {name}: {state}\n"));
4551                    }
4552                }
4553                out.push('\n');
4554            }
4555        }
4556
4557        // Windows activation status
4558        let act_script = r#"
4559try {
4560    $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
4561    if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
4562} catch { "UNKNOWN" }
4563"#;
4564        if let Ok(o) = Command::new("powershell")
4565            .args(["-NoProfile", "-Command", act_script])
4566            .output()
4567        {
4568            let raw = String::from_utf8_lossy(&o.stdout);
4569            match raw.trim() {
4570                "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
4571                "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
4572                _ => out.push_str("Windows activation: Unable to determine\n"),
4573            }
4574        }
4575
4576        // UAC state
4577        let uac_script = r#"
4578$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
4579if ($val -eq 1) { "ON" } else { "OFF" }
4580"#;
4581        if let Ok(o) = Command::new("powershell")
4582            .args(["-NoProfile", "-Command", uac_script])
4583            .output()
4584        {
4585            let raw = String::from_utf8_lossy(&o.stdout);
4586            let state = raw.trim();
4587            let label = if state == "ON" {
4588                "Enabled"
4589            } else {
4590                "DISABLED [!] — recommended to re-enable via secpol.msc"
4591            };
4592            out.push_str(&format!("UAC (User Account Control): {label}\n"));
4593        }
4594    }
4595
4596    #[cfg(not(target_os = "windows"))]
4597    {
4598        if let Ok(o) = Command::new("ufw").arg("status").output() {
4599            let text = String::from_utf8_lossy(&o.stdout);
4600            out.push_str(&format!(
4601                "UFW: {}\n",
4602                text.lines().next().unwrap_or("unknown")
4603            ));
4604        }
4605        if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
4606            if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
4607                out.push_str(&format!("{line}\n"));
4608            }
4609        }
4610    }
4611
4612    Ok(out.trim_end().to_string())
4613}
4614
4615// ── pending_reboot ────────────────────────────────────────────────────────────
4616
4617fn inspect_pending_reboot() -> Result<String, String> {
4618    let mut out = String::from("Host inspection: pending_reboot\n\n");
4619
4620    #[cfg(target_os = "windows")]
4621    {
4622        let script = r#"
4623$reasons = @()
4624if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
4625    $reasons += "Windows Update requires a restart"
4626}
4627if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
4628    $reasons += "Windows component install/update requires a restart"
4629}
4630$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
4631if ($pfro -and $pfro.PendingFileRenameOperations) {
4632    $reasons += "Pending file rename operations (driver or system file replacement)"
4633}
4634if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
4635"#;
4636        let output = Command::new("powershell")
4637            .args(["-NoProfile", "-Command", script])
4638            .output()
4639            .map_err(|e| format!("pending_reboot: {e}"))?;
4640
4641        let raw = String::from_utf8_lossy(&output.stdout);
4642        let text = raw.trim();
4643
4644        if text == "NO_REBOOT_NEEDED" {
4645            out.push_str("No restart required — system is up to date and stable.\n");
4646        } else if text.is_empty() {
4647            out.push_str("Could not determine reboot status.\n");
4648        } else {
4649            out.push_str("[!] A system restart is pending:\n\n");
4650            for reason in text.split("|REASON|") {
4651                out.push_str(&format!("  • {}\n", reason.trim()));
4652            }
4653            out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
4654        }
4655    }
4656
4657    #[cfg(not(target_os = "windows"))]
4658    {
4659        if std::path::Path::new("/var/run/reboot-required").exists() {
4660            out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
4661            if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
4662                out.push_str("Packages requiring restart:\n");
4663                for p in pkgs.lines().take(10) {
4664                    out.push_str(&format!("  • {p}\n"));
4665                }
4666            }
4667        } else {
4668            out.push_str("No restart required.\n");
4669        }
4670    }
4671
4672    Ok(out.trim_end().to_string())
4673}
4674
4675// ── disk_health ───────────────────────────────────────────────────────────────
4676
4677fn inspect_disk_health() -> Result<String, String> {
4678    let mut out = String::from("Host inspection: disk_health\n\n");
4679
4680    #[cfg(target_os = "windows")]
4681    {
4682        let script = r#"
4683try {
4684    $disks = Get-PhysicalDisk -ErrorAction Stop
4685    foreach ($d in $disks) {
4686        $size_gb = [math]::Round($d.Size / 1GB, 0)
4687        $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
4688    }
4689} catch { "ERROR:" + $_.Exception.Message }
4690"#;
4691        let output = Command::new("powershell")
4692            .args(["-NoProfile", "-Command", script])
4693            .output()
4694            .map_err(|e| format!("disk_health: {e}"))?;
4695
4696        let raw = String::from_utf8_lossy(&output.stdout);
4697        let text = raw.trim();
4698
4699        if text.starts_with("ERROR:") {
4700            out.push_str(&format!("Unable to query disk health: {text}\n"));
4701            out.push_str("This may require running as administrator.\n");
4702        } else if text.is_empty() {
4703            out.push_str("No physical disks found.\n");
4704        } else {
4705            out.push_str("Physical Drive Health:\n\n");
4706            for line in text.lines() {
4707                let parts: Vec<&str> = line.splitn(5, '|').collect();
4708                if parts.len() >= 4 {
4709                    let name = parts[0];
4710                    let media = parts[1];
4711                    let size = parts[2];
4712                    let health = parts[3];
4713                    let op_status = parts.get(4).unwrap_or(&"");
4714                    let health_label = match health.trim() {
4715                        "Healthy" => "OK",
4716                        "Warning" => "[!] WARNING",
4717                        "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
4718                        other => other,
4719                    };
4720                    out.push_str(&format!("  {name}\n"));
4721                    out.push_str(&format!("    Type: {media} | Size: {size}\n"));
4722                    out.push_str(&format!("    Health: {health_label}\n"));
4723                    if !op_status.is_empty() {
4724                        out.push_str(&format!("    Status: {op_status}\n"));
4725                    }
4726                    out.push('\n');
4727                }
4728            }
4729        }
4730
4731        // SMART failure prediction (best-effort, may need admin)
4732        let smart_script = r#"
4733try {
4734    Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
4735        ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
4736} catch { "" }
4737"#;
4738        if let Ok(o) = Command::new("powershell")
4739            .args(["-NoProfile", "-Command", smart_script])
4740            .output()
4741        {
4742            let raw2 = String::from_utf8_lossy(&o.stdout);
4743            let text2 = raw2.trim();
4744            if !text2.is_empty() {
4745                let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
4746                if failures.is_empty() {
4747                    out.push_str("SMART failure prediction: No failures predicted\n");
4748                } else {
4749                    out.push_str("[!!] SMART failure predicted on one or more drives:\n");
4750                    for f in failures {
4751                        let name = f.split('|').next().unwrap_or(f);
4752                        out.push_str(&format!("  • {name}\n"));
4753                    }
4754                    out.push_str(
4755                        "\nBack up your data immediately and replace the failing drive.\n",
4756                    );
4757                }
4758            }
4759        }
4760    }
4761
4762    #[cfg(not(target_os = "windows"))]
4763    {
4764        if let Ok(o) = Command::new("lsblk")
4765            .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
4766            .output()
4767        {
4768            let text = String::from_utf8_lossy(&o.stdout);
4769            out.push_str("Block devices:\n");
4770            out.push_str(text.trim());
4771            out.push('\n');
4772        }
4773        if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
4774            let devices = String::from_utf8_lossy(&scan.stdout);
4775            for dev_line in devices.lines().take(4) {
4776                let dev = dev_line.split_whitespace().next().unwrap_or("");
4777                if dev.is_empty() {
4778                    continue;
4779                }
4780                if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
4781                    let health = String::from_utf8_lossy(&o.stdout);
4782                    if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
4783                    {
4784                        out.push_str(&format!("{dev}: {}\n", line.trim()));
4785                    }
4786                }
4787            }
4788        } else {
4789            out.push_str("(install smartmontools for SMART health data)\n");
4790        }
4791    }
4792
4793    Ok(out.trim_end().to_string())
4794}
4795
4796// ── battery ───────────────────────────────────────────────────────────────────
4797
4798fn inspect_battery() -> Result<String, String> {
4799    let mut out = String::from("Host inspection: battery\n\n");
4800
4801    #[cfg(target_os = "windows")]
4802    {
4803        let script = r#"
4804try {
4805    $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction Stop
4806    if (-not $bats) { "NO_BATTERY"; exit }
4807    foreach ($b in $bats) {
4808        $status = switch ($b.BatteryStatus) {
4809            1 { "Discharging (on battery)" }
4810            2 { "AC power - fully charged" }
4811            3 { "AC power - charging" }
4812            6 { "AC power - charging" }
4813            7 { "AC power - charging" }
4814            default { "Status $($b.BatteryStatus)" }
4815        }
4816        $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $status + "|" + $b.EstimatedRunTime
4817    }
4818} catch { "ERROR:" + $_.Exception.Message }
4819"#;
4820        let output = Command::new("powershell")
4821            .args(["-NoProfile", "-Command", script])
4822            .output()
4823            .map_err(|e| format!("battery: {e}"))?;
4824
4825        let raw = String::from_utf8_lossy(&output.stdout);
4826        let text = raw.trim();
4827
4828        if text == "NO_BATTERY" {
4829            out.push_str("No battery detected — desktop or AC-only system.\n");
4830            return Ok(out.trim_end().to_string());
4831        }
4832        if text.starts_with("ERROR:") {
4833            out.push_str(&format!("Unable to query battery: {text}\n"));
4834            return Ok(out.trim_end().to_string());
4835        }
4836
4837        for line in text.lines() {
4838            let parts: Vec<&str> = line.splitn(4, '|').collect();
4839            if parts.len() >= 3 {
4840                let name = parts[0];
4841                let charge: i64 = parts[1].parse().unwrap_or(-1);
4842                let status = parts[2];
4843                let time_rem: i64 = parts.get(3).and_then(|v| v.parse().ok()).unwrap_or(-1);
4844
4845                out.push_str(&format!("Battery: {name}\n"));
4846                if charge >= 0 {
4847                    let bar_filled = (charge as usize * 20) / 100;
4848                    out.push_str(&format!(
4849                        "  Charge: [{}{}] {}%\n",
4850                        "#".repeat(bar_filled),
4851                        ".".repeat(20 - bar_filled),
4852                        charge
4853                    ));
4854                }
4855                out.push_str(&format!("  Status: {status}\n"));
4856                // Windows returns 71582788 as "unknown remaining time"
4857                if time_rem > 0 && time_rem < 71_582_788 {
4858                    let hours = time_rem / 60;
4859                    let mins = time_rem % 60;
4860                    out.push_str(&format!("  Estimated time remaining: {hours}h {mins}m\n"));
4861                }
4862                out.push('\n');
4863            }
4864        }
4865
4866        // Battery wear level (requires admin for CIM battery namespace)
4867        let wear_script = r#"
4868try {
4869    $full = Get-CimInstance -Namespace root\cimv2 -ClassName BatteryFullChargedCapacity -ErrorAction Stop | Select-Object -First 1
4870    $static = Get-CimInstance -Namespace root\cimv2 -ClassName BatteryStaticData -ErrorAction Stop | Select-Object -First 1
4871    if ($full -and $static -and $static.DesignedCapacity -gt 0) {
4872        $pct = [math]::Round(($full.FullChargedCapacity / $static.DesignedCapacity) * 100, 1)
4873        $full.FullChargedCapacity.ToString() + "|" + $static.DesignedCapacity.ToString() + "|" + $pct.ToString()
4874    } else { "UNKNOWN" }
4875} catch { "UNKNOWN" }
4876"#;
4877        if let Ok(o) = Command::new("powershell")
4878            .args(["-NoProfile", "-Command", wear_script])
4879            .output()
4880        {
4881            let raw2 = String::from_utf8_lossy(&o.stdout);
4882            let t = raw2.trim();
4883            if t != "UNKNOWN" && !t.is_empty() {
4884                let parts: Vec<&str> = t.splitn(3, '|').collect();
4885                if parts.len() == 3 {
4886                    let full: i64 = parts[0].parse().unwrap_or(0);
4887                    let design: i64 = parts[1].parse().unwrap_or(0);
4888                    let pct: f64 = parts[2].parse().unwrap_or(0.0);
4889                    out.push_str(&format!(
4890                        "Battery wear level: {pct:.1}% of original capacity\n"
4891                    ));
4892                    out.push_str(&format!(
4893                        "  Current full charge: {full} mWh / Design: {design} mWh\n"
4894                    ));
4895                    if pct < 50.0 {
4896                        out.push_str("  [!] Significantly degraded — consider replacement\n");
4897                    } else if pct < 75.0 {
4898                        out.push_str("  [-] Noticeable wear\n");
4899                    } else {
4900                        out.push_str("  Battery health is good\n");
4901                    }
4902                }
4903            }
4904        }
4905    }
4906
4907    #[cfg(not(target_os = "windows"))]
4908    {
4909        let power_path = std::path::Path::new("/sys/class/power_supply");
4910        let mut found = false;
4911        if power_path.exists() {
4912            if let Ok(entries) = std::fs::read_dir(power_path) {
4913                for entry in entries.flatten() {
4914                    let p = entry.path();
4915                    if let Ok(t) = std::fs::read_to_string(p.join("type")) {
4916                        if t.trim() == "Battery" {
4917                            found = true;
4918                            let name = p
4919                                .file_name()
4920                                .unwrap_or_default()
4921                                .to_string_lossy()
4922                                .to_string();
4923                            out.push_str(&format!("Battery: {name}\n"));
4924                            let read = |f: &str| {
4925                                std::fs::read_to_string(p.join(f))
4926                                    .ok()
4927                                    .map(|s| s.trim().to_string())
4928                            };
4929                            if let Some(cap) = read("capacity") {
4930                                out.push_str(&format!("  Charge: {cap}%\n"));
4931                            }
4932                            if let Some(status) = read("status") {
4933                                out.push_str(&format!("  Status: {status}\n"));
4934                            }
4935                            if let (Some(full), Some(design)) =
4936                                (read("energy_full"), read("energy_full_design"))
4937                            {
4938                                if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
4939                                {
4940                                    if d > 0.0 {
4941                                        out.push_str(&format!(
4942                                            "  Wear level: {:.1}% of design capacity\n",
4943                                            (f / d) * 100.0
4944                                        ));
4945                                    }
4946                                }
4947                            }
4948                        }
4949                    }
4950                }
4951            }
4952        }
4953        if !found {
4954            out.push_str("No battery found.\n");
4955        }
4956    }
4957
4958    Ok(out.trim_end().to_string())
4959}
4960
4961// ── recent_crashes ────────────────────────────────────────────────────────────
4962
4963fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
4964    let mut out = String::from("Host inspection: recent_crashes\n\n");
4965    let n = max_entries.clamp(1, 30);
4966
4967    #[cfg(target_os = "windows")]
4968    {
4969        // BSODs / unexpected shutdowns (EventID 41 = kernel power, 1001 = BugCheck)
4970        let bsod_script = format!(
4971            r#"
4972try {{
4973    $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
4974    if ($events) {{
4975        $events | ForEach-Object {{
4976            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
4977        }}
4978    }} else {{ "NO_BSOD" }}
4979}} catch {{ "ERROR:" + $_.Exception.Message }}"#
4980        );
4981
4982        if let Ok(o) = Command::new("powershell")
4983            .args(["-NoProfile", "-Command", &bsod_script])
4984            .output()
4985        {
4986            let raw = String::from_utf8_lossy(&o.stdout);
4987            let text = raw.trim();
4988            if text == "NO_BSOD" {
4989                out.push_str("System crashes (BSOD/kernel): None in recent history\n");
4990            } else if text.starts_with("ERROR:") {
4991                out.push_str("System crashes: unable to query\n");
4992            } else {
4993                out.push_str("System crashes / unexpected shutdowns:\n");
4994                for line in text.lines() {
4995                    let parts: Vec<&str> = line.splitn(3, '|').collect();
4996                    if parts.len() >= 3 {
4997                        let time = parts[0];
4998                        let id = parts[1];
4999                        let msg = parts[2];
5000                        let label = if id == "41" {
5001                            "Unexpected shutdown"
5002                        } else {
5003                            "BSOD (BugCheck)"
5004                        };
5005                        out.push_str(&format!("  [{time}] {label}: {msg}\n"));
5006                    }
5007                }
5008                out.push('\n');
5009            }
5010        }
5011
5012        // Application crashes (EventID 1000 = app crash, 1002 = app hang)
5013        let app_script = format!(
5014            r#"
5015try {{
5016    $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
5017    if ($crashes) {{
5018        $crashes | ForEach-Object {{
5019            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5020        }}
5021    }} else {{ "NO_CRASHES" }}
5022}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
5023        );
5024
5025        if let Ok(o) = Command::new("powershell")
5026            .args(["-NoProfile", "-Command", &app_script])
5027            .output()
5028        {
5029            let raw = String::from_utf8_lossy(&o.stdout);
5030            let text = raw.trim();
5031            if text == "NO_CRASHES" {
5032                out.push_str("Application crashes: None in recent history\n");
5033            } else if text.starts_with("ERROR_APP:") {
5034                out.push_str("Application crashes: unable to query\n");
5035            } else {
5036                out.push_str("Application crashes:\n");
5037                for line in text.lines().take(n) {
5038                    let parts: Vec<&str> = line.splitn(2, '|').collect();
5039                    if parts.len() >= 2 {
5040                        out.push_str(&format!("  [{}] {}\n", parts[0], parts[1]));
5041                    }
5042                }
5043            }
5044        }
5045    }
5046
5047    #[cfg(not(target_os = "windows"))]
5048    {
5049        let n_str = n.to_string();
5050        if let Ok(o) = Command::new("journalctl")
5051            .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
5052            .output()
5053        {
5054            let text = String::from_utf8_lossy(&o.stdout);
5055            let trimmed = text.trim();
5056            if trimmed.is_empty() || trimmed.contains("No entries") {
5057                out.push_str("No kernel panics or critical crashes found.\n");
5058            } else {
5059                out.push_str("Kernel critical events:\n");
5060                out.push_str(trimmed);
5061                out.push('\n');
5062            }
5063        }
5064        if let Ok(o) = Command::new("coredumpctl")
5065            .args(["list", "--no-pager"])
5066            .output()
5067        {
5068            let text = String::from_utf8_lossy(&o.stdout);
5069            let count = text
5070                .lines()
5071                .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
5072                .count();
5073            if count > 0 {
5074                out.push_str(&format!(
5075                    "\nCore dumps on file: {count}\n  → Run: coredumpctl list\n"
5076                ));
5077            }
5078        }
5079    }
5080
5081    Ok(out.trim_end().to_string())
5082}
5083
5084// ── scheduled_tasks ───────────────────────────────────────────────────────────
5085
5086fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
5087    let mut out = String::from("Host inspection: scheduled_tasks\n\n");
5088    let n = max_entries.clamp(1, 30);
5089
5090    #[cfg(target_os = "windows")]
5091    {
5092        let script = format!(
5093            r#"
5094try {{
5095    $tasks = Get-ScheduledTask -ErrorAction Stop |
5096        Where-Object {{ $_.State -ne 'Disabled' }} |
5097        ForEach-Object {{
5098            $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
5099            $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
5100                $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
5101            }} else {{ "never" }}
5102            $exec = ($_.Actions | Select-Object -First 1).Execute
5103            if (-not $exec) {{ $exec = "(no exec)" }}
5104            $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $exec
5105        }}
5106    $tasks | Select-Object -First {n}
5107}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5108        );
5109
5110        let output = Command::new("powershell")
5111            .args(["-NoProfile", "-Command", &script])
5112            .output()
5113            .map_err(|e| format!("scheduled_tasks: {e}"))?;
5114
5115        let raw = String::from_utf8_lossy(&output.stdout);
5116        let text = raw.trim();
5117
5118        if text.starts_with("ERROR:") {
5119            out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
5120        } else if text.is_empty() {
5121            out.push_str("No active scheduled tasks found.\n");
5122        } else {
5123            out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
5124            for line in text.lines() {
5125                let parts: Vec<&str> = line.splitn(5, '|').collect();
5126                if parts.len() >= 4 {
5127                    let name = parts[0];
5128                    let path = parts[1];
5129                    let state = parts[2];
5130                    let last = parts[3];
5131                    let exec = parts.get(4).unwrap_or(&"").trim();
5132                    let display_path = path.trim_matches('\\');
5133                    let display_path = if display_path.is_empty() {
5134                        "Root"
5135                    } else {
5136                        display_path
5137                    };
5138                    out.push_str(&format!("  {name} [{display_path}]\n"));
5139                    out.push_str(&format!("    State: {state} | Last run: {last}\n"));
5140                    if !exec.is_empty() && exec != "(no exec)" {
5141                        let short = if exec.len() > 80 { &exec[..80] } else { exec };
5142                        out.push_str(&format!("    Runs: {short}\n"));
5143                    }
5144                }
5145            }
5146        }
5147    }
5148
5149    #[cfg(not(target_os = "windows"))]
5150    {
5151        if let Ok(o) = Command::new("systemctl")
5152            .args(["list-timers", "--no-pager", "--all"])
5153            .output()
5154        {
5155            let text = String::from_utf8_lossy(&o.stdout);
5156            out.push_str("Systemd timers:\n");
5157            for l in text
5158                .lines()
5159                .filter(|l| {
5160                    !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
5161                })
5162                .take(n)
5163            {
5164                out.push_str(&format!("  {l}\n"));
5165            }
5166            out.push('\n');
5167        }
5168        if let Ok(o) = Command::new("crontab").arg("-l").output() {
5169            let text = String::from_utf8_lossy(&o.stdout);
5170            let jobs: Vec<&str> = text
5171                .lines()
5172                .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
5173                .collect();
5174            if !jobs.is_empty() {
5175                out.push_str("User crontab:\n");
5176                for j in jobs.iter().take(n) {
5177                    out.push_str(&format!("  {j}\n"));
5178                }
5179            }
5180        }
5181    }
5182
5183    Ok(out.trim_end().to_string())
5184}
5185
5186// ── dev_conflicts ─────────────────────────────────────────────────────────────
5187
5188fn inspect_dev_conflicts() -> Result<String, String> {
5189    let mut out = String::from("Host inspection: dev_conflicts\n\n");
5190    let mut conflicts: Vec<String> = Vec::new();
5191    let mut notes: Vec<String> = Vec::new();
5192
5193    // ── Node.js / version managers ────────────────────────────────────────────
5194    {
5195        let node_ver = Command::new("node")
5196            .arg("--version")
5197            .output()
5198            .ok()
5199            .and_then(|o| String::from_utf8(o.stdout).ok())
5200            .map(|s| s.trim().to_string());
5201        let nvm_active = Command::new("nvm")
5202            .arg("current")
5203            .output()
5204            .ok()
5205            .and_then(|o| String::from_utf8(o.stdout).ok())
5206            .map(|s| s.trim().to_string())
5207            .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
5208        let fnm_active = Command::new("fnm")
5209            .arg("current")
5210            .output()
5211            .ok()
5212            .and_then(|o| String::from_utf8(o.stdout).ok())
5213            .map(|s| s.trim().to_string())
5214            .filter(|s| !s.is_empty() && !s.contains("none"));
5215        let volta_active = Command::new("volta")
5216            .args(["which", "node"])
5217            .output()
5218            .ok()
5219            .and_then(|o| String::from_utf8(o.stdout).ok())
5220            .map(|s| s.trim().to_string())
5221            .filter(|s| !s.is_empty());
5222
5223        out.push_str("Node.js:\n");
5224        if let Some(ref v) = node_ver {
5225            out.push_str(&format!("  Active: {v}\n"));
5226        } else {
5227            out.push_str("  Not installed\n");
5228        }
5229        let managers: Vec<&str> = [
5230            nvm_active.as_deref(),
5231            fnm_active.as_deref(),
5232            volta_active.as_deref(),
5233        ]
5234        .iter()
5235        .filter_map(|x| *x)
5236        .collect();
5237        if managers.len() > 1 {
5238            conflicts.push(format!(
5239                "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
5240            ));
5241        } else if !managers.is_empty() {
5242            out.push_str(&format!("  Version manager: {}\n", managers[0]));
5243        }
5244        out.push('\n');
5245    }
5246
5247    // ── Python ────────────────────────────────────────────────────────────────
5248    {
5249        let py3 = Command::new("python3")
5250            .arg("--version")
5251            .output()
5252            .ok()
5253            .and_then(|o| {
5254                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5255                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5256                let v = if stdout.is_empty() { stderr } else { stdout };
5257                if v.is_empty() {
5258                    None
5259                } else {
5260                    Some(v)
5261                }
5262            });
5263        let py = Command::new("python")
5264            .arg("--version")
5265            .output()
5266            .ok()
5267            .and_then(|o| {
5268                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5269                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5270                let v = if stdout.is_empty() { stderr } else { stdout };
5271                if v.is_empty() {
5272                    None
5273                } else {
5274                    Some(v)
5275                }
5276            });
5277        let pyenv = Command::new("pyenv")
5278            .arg("version")
5279            .output()
5280            .ok()
5281            .and_then(|o| String::from_utf8(o.stdout).ok())
5282            .map(|s| s.trim().to_string())
5283            .filter(|s| !s.is_empty());
5284        let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
5285
5286        out.push_str("Python:\n");
5287        match (&py3, &py) {
5288            (Some(v3), Some(v)) if v3 != v => {
5289                out.push_str(&format!("  python3: {v3}\n  python:  {v}\n"));
5290                if v.contains("2.") {
5291                    conflicts.push(
5292                        "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
5293                    );
5294                } else {
5295                    notes.push(
5296                        "python and python3 resolve to different minor versions.".to_string(),
5297                    );
5298                }
5299            }
5300            (Some(v3), None) => out.push_str(&format!("  python3: {v3}\n")),
5301            (None, Some(v)) => out.push_str(&format!("  python: {v}\n")),
5302            (Some(v3), Some(_)) => out.push_str(&format!("  {v3}\n")),
5303            (None, None) => out.push_str("  Not installed\n"),
5304        }
5305        if let Some(ref pe) = pyenv {
5306            out.push_str(&format!("  pyenv: {pe}\n"));
5307        }
5308        if let Some(env) = conda_env {
5309            if env == "base" {
5310                notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
5311            } else {
5312                out.push_str(&format!("  conda env: {env}\n"));
5313            }
5314        }
5315        out.push('\n');
5316    }
5317
5318    // ── Rust / Cargo ──────────────────────────────────────────────────────────
5319    {
5320        let toolchain = Command::new("rustup")
5321            .args(["show", "active-toolchain"])
5322            .output()
5323            .ok()
5324            .and_then(|o| String::from_utf8(o.stdout).ok())
5325            .map(|s| s.trim().to_string())
5326            .filter(|s| !s.is_empty());
5327        let cargo_ver = Command::new("cargo")
5328            .arg("--version")
5329            .output()
5330            .ok()
5331            .and_then(|o| String::from_utf8(o.stdout).ok())
5332            .map(|s| s.trim().to_string());
5333        let rustc_ver = Command::new("rustc")
5334            .arg("--version")
5335            .output()
5336            .ok()
5337            .and_then(|o| String::from_utf8(o.stdout).ok())
5338            .map(|s| s.trim().to_string());
5339
5340        out.push_str("Rust:\n");
5341        if let Some(ref t) = toolchain {
5342            out.push_str(&format!("  Active toolchain: {t}\n"));
5343        }
5344        if let Some(ref c) = cargo_ver {
5345            out.push_str(&format!("  {c}\n"));
5346        }
5347        if let Some(ref r) = rustc_ver {
5348            out.push_str(&format!("  {r}\n"));
5349        }
5350        if cargo_ver.is_none() && rustc_ver.is_none() {
5351            out.push_str("  Not installed\n");
5352        }
5353
5354        // Detect system rust that might shadow rustup
5355        #[cfg(not(target_os = "windows"))]
5356        if let Ok(o) = Command::new("which").arg("rustc").output() {
5357            let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
5358            if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
5359                conflicts.push(format!(
5360                    "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
5361                ));
5362            }
5363        }
5364        out.push('\n');
5365    }
5366
5367    // ── Git ───────────────────────────────────────────────────────────────────
5368    {
5369        let git_ver = Command::new("git")
5370            .arg("--version")
5371            .output()
5372            .ok()
5373            .and_then(|o| String::from_utf8(o.stdout).ok())
5374            .map(|s| s.trim().to_string());
5375        out.push_str("Git:\n");
5376        if let Some(ref v) = git_ver {
5377            out.push_str(&format!("  {v}\n"));
5378            let email = Command::new("git")
5379                .args(["config", "--global", "user.email"])
5380                .output()
5381                .ok()
5382                .and_then(|o| String::from_utf8(o.stdout).ok())
5383                .map(|s| s.trim().to_string());
5384            if let Some(ref e) = email {
5385                if e.is_empty() {
5386                    notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
5387                } else {
5388                    out.push_str(&format!("  user.email: {e}\n"));
5389                }
5390            }
5391            let gpg_sign = Command::new("git")
5392                .args(["config", "--global", "commit.gpgsign"])
5393                .output()
5394                .ok()
5395                .and_then(|o| String::from_utf8(o.stdout).ok())
5396                .map(|s| s.trim().to_string());
5397            if gpg_sign.as_deref() == Some("true") {
5398                let key = Command::new("git")
5399                    .args(["config", "--global", "user.signingkey"])
5400                    .output()
5401                    .ok()
5402                    .and_then(|o| String::from_utf8(o.stdout).ok())
5403                    .map(|s| s.trim().to_string());
5404                if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
5405                    conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
5406                }
5407            }
5408        } else {
5409            out.push_str("  Not installed\n");
5410        }
5411        out.push('\n');
5412    }
5413
5414    // ── PATH duplicates ───────────────────────────────────────────────────────
5415    {
5416        let path_env = std::env::var("PATH").unwrap_or_default();
5417        let sep = if cfg!(windows) { ';' } else { ':' };
5418        let mut seen = HashSet::new();
5419        let mut dupes: Vec<String> = Vec::new();
5420        for p in path_env.split(sep) {
5421            let norm = p.trim().to_lowercase();
5422            if !norm.is_empty() && !seen.insert(norm) {
5423                dupes.push(p.to_string());
5424            }
5425        }
5426        if !dupes.is_empty() {
5427            let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
5428            notes.push(format!(
5429                "Duplicate PATH entries: {} {}",
5430                shown.join(", "),
5431                if dupes.len() > 3 {
5432                    format!("+{} more", dupes.len() - 3)
5433                } else {
5434                    String::new()
5435                }
5436            ));
5437        }
5438    }
5439
5440    // ── Summary ───────────────────────────────────────────────────────────────
5441    if conflicts.is_empty() && notes.is_empty() {
5442        out.push_str("No conflicts detected — dev environment looks clean.\n");
5443    } else {
5444        if !conflicts.is_empty() {
5445            out.push_str("CONFLICTS:\n");
5446            for c in &conflicts {
5447                out.push_str(&format!("  [!] {c}\n"));
5448            }
5449            out.push('\n');
5450        }
5451        if !notes.is_empty() {
5452            out.push_str("NOTES:\n");
5453            for n in &notes {
5454                out.push_str(&format!("  [-] {n}\n"));
5455            }
5456        }
5457    }
5458
5459    Ok(out.trim_end().to_string())
5460}
5461
5462// ── connectivity ──────────────────────────────────────────────────────────────
5463
5464fn inspect_connectivity() -> Result<String, String> {
5465    let mut out = String::from("Host inspection: connectivity\n\n");
5466
5467    #[cfg(target_os = "windows")]
5468    {
5469        let inet_script = r#"
5470try {
5471    $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
5472    if ($r) { "REACHABLE" } else { "UNREACHABLE" }
5473} catch { "ERROR:" + $_.Exception.Message }
5474"#;
5475        if let Ok(o) = Command::new("powershell")
5476            .args(["-NoProfile", "-Command", inet_script])
5477            .output()
5478        {
5479            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5480            match text.as_str() {
5481                "REACHABLE" => out.push_str("Internet: reachable\n"),
5482                "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
5483                _ => out.push_str(&format!(
5484                    "Internet: {}\n",
5485                    text.trim_start_matches("ERROR:").trim()
5486                )),
5487            }
5488        }
5489
5490        let dns_script = r#"
5491try {
5492    Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
5493    "DNS:ok"
5494} catch { "DNS:fail:" + $_.Exception.Message }
5495"#;
5496        if let Ok(o) = Command::new("powershell")
5497            .args(["-NoProfile", "-Command", dns_script])
5498            .output()
5499        {
5500            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5501            if text == "DNS:ok" {
5502                out.push_str("DNS: resolving correctly\n");
5503            } else {
5504                let detail = text.trim_start_matches("DNS:fail:").trim();
5505                out.push_str(&format!("DNS: failed — {}\n", detail));
5506            }
5507        }
5508
5509        let gw_script = r#"
5510(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
5511"#;
5512        if let Ok(o) = Command::new("powershell")
5513            .args(["-NoProfile", "-Command", gw_script])
5514            .output()
5515        {
5516            let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
5517            if !gw.is_empty() && gw != "0.0.0.0" {
5518                out.push_str(&format!("Default gateway: {}\n", gw));
5519            }
5520        }
5521    }
5522
5523    #[cfg(not(target_os = "windows"))]
5524    {
5525        let reachable = Command::new("ping")
5526            .args(["-c", "1", "-W", "2", "8.8.8.8"])
5527            .output()
5528            .map(|o| o.status.success())
5529            .unwrap_or(false);
5530        out.push_str(if reachable {
5531            "Internet: reachable\n"
5532        } else {
5533            "Internet: unreachable\n"
5534        });
5535        let dns_ok = Command::new("getent")
5536            .args(["hosts", "dns.google"])
5537            .output()
5538            .map(|o| o.status.success())
5539            .unwrap_or(false);
5540        out.push_str(if dns_ok {
5541            "DNS: resolving correctly\n"
5542        } else {
5543            "DNS: failed\n"
5544        });
5545        if let Ok(o) = Command::new("ip")
5546            .args(["route", "show", "default"])
5547            .output()
5548        {
5549            let text = String::from_utf8_lossy(&o.stdout);
5550            if let Some(line) = text.lines().next() {
5551                out.push_str(&format!("Default gateway: {}\n", line.trim()));
5552            }
5553        }
5554    }
5555
5556    Ok(out.trim_end().to_string())
5557}
5558
5559// ── wifi ──────────────────────────────────────────────────────────────────────
5560
5561fn inspect_wifi() -> Result<String, String> {
5562    let mut out = String::from("Host inspection: wifi\n\n");
5563
5564    #[cfg(target_os = "windows")]
5565    {
5566        let output = Command::new("netsh")
5567            .args(["wlan", "show", "interfaces"])
5568            .output()
5569            .map_err(|e| format!("wifi: {e}"))?;
5570        let text = String::from_utf8_lossy(&output.stdout).to_string();
5571
5572        if text.contains("There is no wireless interface") || text.trim().is_empty() {
5573            out.push_str("No wireless interface detected on this machine.\n");
5574            return Ok(out.trim_end().to_string());
5575        }
5576
5577        let fields = [
5578            ("SSID", "SSID"),
5579            ("State", "State"),
5580            ("Signal", "Signal"),
5581            ("Radio type", "Radio type"),
5582            ("Channel", "Channel"),
5583            ("Receive rate (Mbps)", "Download speed (Mbps)"),
5584            ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
5585            ("Authentication", "Authentication"),
5586            ("Network type", "Network type"),
5587        ];
5588
5589        let mut any = false;
5590        for line in text.lines() {
5591            let trimmed = line.trim();
5592            for (key, label) in &fields {
5593                if trimmed.starts_with(key) && trimmed.contains(':') {
5594                    let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
5595                    if !val.is_empty() {
5596                        out.push_str(&format!("  {label}: {val}\n"));
5597                        any = true;
5598                    }
5599                }
5600            }
5601        }
5602        if !any {
5603            out.push_str("  (Wi-Fi adapter disconnected or no active connection)\n");
5604        }
5605    }
5606
5607    #[cfg(not(target_os = "windows"))]
5608    {
5609        if let Ok(o) = Command::new("nmcli")
5610            .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
5611            .output()
5612        {
5613            let text = String::from_utf8_lossy(&o.stdout).to_string();
5614            let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
5615            if lines.is_empty() {
5616                out.push_str("No Wi-Fi devices found.\n");
5617            } else {
5618                for l in lines {
5619                    out.push_str(&format!("  {l}\n"));
5620                }
5621            }
5622        } else if let Ok(o) = Command::new("iwconfig").output() {
5623            let text = String::from_utf8_lossy(&o.stdout).to_string();
5624            if !text.trim().is_empty() {
5625                out.push_str(text.trim());
5626                out.push('\n');
5627            }
5628        } else {
5629            out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
5630        }
5631    }
5632
5633    Ok(out.trim_end().to_string())
5634}
5635
5636// ── connections ───────────────────────────────────────────────────────────────
5637
5638fn inspect_connections(max_entries: usize) -> Result<String, String> {
5639    let mut out = String::from("Host inspection: connections\n\n");
5640    let n = max_entries.clamp(1, 25);
5641
5642    #[cfg(target_os = "windows")]
5643    {
5644        let script = format!(
5645            r#"
5646try {{
5647    $procs = @{{}}
5648    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
5649    $all = Get-NetTCPConnection -State Established -ErrorAction Stop |
5650        Sort-Object RemoteAddress
5651    "TOTAL:" + $all.Count
5652    $all | Select-Object -First {n} | ForEach-Object {{
5653        $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "pid:" + $_.OwningProcess }}
5654        $pname + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
5655    }}
5656}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5657        );
5658
5659        let output = Command::new("powershell")
5660            .args(["-NoProfile", "-Command", &script])
5661            .output()
5662            .map_err(|e| format!("connections: {e}"))?;
5663
5664        let raw = String::from_utf8_lossy(&output.stdout);
5665        let text = raw.trim();
5666
5667        if text.starts_with("ERROR:") {
5668            out.push_str(&format!("Unable to query connections: {text}\n"));
5669        } else {
5670            let mut total = 0usize;
5671            let mut rows = Vec::new();
5672            for line in text.lines() {
5673                if let Some(rest) = line.strip_prefix("TOTAL:") {
5674                    total = rest.trim().parse().unwrap_or(0);
5675                } else {
5676                    rows.push(line);
5677                }
5678            }
5679            out.push_str(&format!("Established TCP connections: {total}\n\n"));
5680            for row in &rows {
5681                let parts: Vec<&str> = row.splitn(3, '|').collect();
5682                if parts.len() == 3 {
5683                    out.push_str(&format!("  {} | {} → {}\n", parts[0], parts[1], parts[2]));
5684                }
5685            }
5686            if total > n {
5687                out.push_str(&format!(
5688                    "\n  ... {} more connections not shown\n",
5689                    total.saturating_sub(n)
5690                ));
5691            }
5692        }
5693    }
5694
5695    #[cfg(not(target_os = "windows"))]
5696    {
5697        if let Ok(o) = Command::new("ss")
5698            .args(["-tnp", "state", "established"])
5699            .output()
5700        {
5701            let text = String::from_utf8_lossy(&o.stdout);
5702            let lines: Vec<&str> = text
5703                .lines()
5704                .skip(1)
5705                .filter(|l| !l.trim().is_empty())
5706                .collect();
5707            out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
5708            for line in lines.iter().take(n) {
5709                out.push_str(&format!("  {}\n", line.trim()));
5710            }
5711            if lines.len() > n {
5712                out.push_str(&format!("\n  ... {} more not shown\n", lines.len() - n));
5713            }
5714        } else {
5715            out.push_str("ss not available — install iproute2\n");
5716        }
5717    }
5718
5719    Ok(out.trim_end().to_string())
5720}
5721
5722// ── vpn ───────────────────────────────────────────────────────────────────────
5723
5724fn inspect_vpn() -> Result<String, String> {
5725    let mut out = String::from("Host inspection: vpn\n\n");
5726
5727    #[cfg(target_os = "windows")]
5728    {
5729        let script = r#"
5730try {
5731    $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
5732        $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
5733        $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
5734    }
5735    if ($vpn) {
5736        foreach ($a in $vpn) {
5737            $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
5738        }
5739    } else { "NONE" }
5740} catch { "ERROR:" + $_.Exception.Message }
5741"#;
5742        let output = Command::new("powershell")
5743            .args(["-NoProfile", "-Command", script])
5744            .output()
5745            .map_err(|e| format!("vpn: {e}"))?;
5746
5747        let raw = String::from_utf8_lossy(&output.stdout);
5748        let text = raw.trim();
5749
5750        if text == "NONE" {
5751            out.push_str("No VPN adapters detected — no active VPN connection found.\n");
5752        } else if text.starts_with("ERROR:") {
5753            out.push_str(&format!("Unable to query adapters: {text}\n"));
5754        } else {
5755            out.push_str("VPN adapters:\n\n");
5756            for line in text.lines() {
5757                let parts: Vec<&str> = line.splitn(4, '|').collect();
5758                if parts.len() >= 3 {
5759                    let name = parts[0];
5760                    let desc = parts[1];
5761                    let status = parts[2];
5762                    let media = parts.get(3).unwrap_or(&"unknown");
5763                    let label = if status.trim() == "Up" {
5764                        "CONNECTED"
5765                    } else {
5766                        "disconnected"
5767                    };
5768                    out.push_str(&format!(
5769                        "  {name} [{label}]\n    {desc}\n    Status: {status} | Media: {media}\n\n"
5770                    ));
5771                }
5772            }
5773        }
5774
5775        // Windows built-in VPN connections
5776        let ras_script = r#"
5777try {
5778    $c = Get-VpnConnection -ErrorAction Stop
5779    if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
5780    else { "NO_RAS" }
5781} catch { "NO_RAS" }
5782"#;
5783        if let Ok(o) = Command::new("powershell")
5784            .args(["-NoProfile", "-Command", ras_script])
5785            .output()
5786        {
5787            let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
5788            if t != "NO_RAS" && !t.is_empty() {
5789                out.push_str("Windows VPN connections:\n");
5790                for line in t.lines() {
5791                    let parts: Vec<&str> = line.splitn(3, '|').collect();
5792                    if parts.len() >= 2 {
5793                        let name = parts[0];
5794                        let status = parts[1];
5795                        let server = parts.get(2).unwrap_or(&"");
5796                        out.push_str(&format!("  {name} → {server} [{status}]\n"));
5797                    }
5798                }
5799            }
5800        }
5801    }
5802
5803    #[cfg(not(target_os = "windows"))]
5804    {
5805        if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
5806            let text = String::from_utf8_lossy(&o.stdout);
5807            let vpn_ifaces: Vec<&str> = text
5808                .lines()
5809                .filter(|l| {
5810                    l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
5811                })
5812                .collect();
5813            if vpn_ifaces.is_empty() {
5814                out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
5815            } else {
5816                out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
5817                for l in vpn_ifaces {
5818                    out.push_str(&format!("  {}\n", l.trim()));
5819                }
5820            }
5821        }
5822    }
5823
5824    Ok(out.trim_end().to_string())
5825}
5826
5827// ── proxy ─────────────────────────────────────────────────────────────────────
5828
5829fn inspect_proxy() -> Result<String, String> {
5830    let mut out = String::from("Host inspection: proxy\n\n");
5831
5832    #[cfg(target_os = "windows")]
5833    {
5834        let script = r#"
5835$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
5836if ($ie) {
5837    "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
5838} else { "NONE" }
5839"#;
5840        if let Ok(o) = Command::new("powershell")
5841            .args(["-NoProfile", "-Command", script])
5842            .output()
5843        {
5844            let raw = String::from_utf8_lossy(&o.stdout);
5845            let text = raw.trim();
5846            if text != "NONE" && !text.is_empty() {
5847                let get = |key: &str| -> &str {
5848                    text.split('|')
5849                        .find(|s| s.starts_with(key))
5850                        .and_then(|s| s.splitn(2, ':').nth(1))
5851                        .unwrap_or("")
5852                };
5853                let enabled = get("ENABLE");
5854                let server = get("SERVER");
5855                let overrides = get("OVERRIDE");
5856                out.push_str("WinINET / IE proxy:\n");
5857                out.push_str(&format!(
5858                    "  Enabled: {}\n",
5859                    if enabled == "1" { "yes" } else { "no" }
5860                ));
5861                if !server.is_empty() && server != "None" {
5862                    out.push_str(&format!("  Proxy server: {server}\n"));
5863                }
5864                if !overrides.is_empty() && overrides != "None" {
5865                    out.push_str(&format!("  Bypass list: {overrides}\n"));
5866                }
5867                out.push('\n');
5868            }
5869        }
5870
5871        if let Ok(o) = Command::new("netsh")
5872            .args(["winhttp", "show", "proxy"])
5873            .output()
5874        {
5875            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5876            out.push_str("WinHTTP proxy:\n");
5877            for line in text.lines() {
5878                let l = line.trim();
5879                if !l.is_empty() {
5880                    out.push_str(&format!("  {l}\n"));
5881                }
5882            }
5883            out.push('\n');
5884        }
5885
5886        let mut env_found = false;
5887        for var in &[
5888            "http_proxy",
5889            "https_proxy",
5890            "HTTP_PROXY",
5891            "HTTPS_PROXY",
5892            "no_proxy",
5893            "NO_PROXY",
5894        ] {
5895            if let Ok(val) = std::env::var(var) {
5896                if !env_found {
5897                    out.push_str("Environment proxy variables:\n");
5898                    env_found = true;
5899                }
5900                out.push_str(&format!("  {var}: {val}\n"));
5901            }
5902        }
5903        if !env_found {
5904            out.push_str("No proxy environment variables set.\n");
5905        }
5906    }
5907
5908    #[cfg(not(target_os = "windows"))]
5909    {
5910        let mut found = false;
5911        for var in &[
5912            "http_proxy",
5913            "https_proxy",
5914            "HTTP_PROXY",
5915            "HTTPS_PROXY",
5916            "no_proxy",
5917            "NO_PROXY",
5918            "ALL_PROXY",
5919            "all_proxy",
5920        ] {
5921            if let Ok(val) = std::env::var(var) {
5922                if !found {
5923                    out.push_str("Proxy environment variables:\n");
5924                    found = true;
5925                }
5926                out.push_str(&format!("  {var}: {val}\n"));
5927            }
5928        }
5929        if !found {
5930            out.push_str("No proxy environment variables set.\n");
5931        }
5932        if let Ok(content) = std::fs::read_to_string("/etc/environment") {
5933            let proxy_lines: Vec<&str> = content
5934                .lines()
5935                .filter(|l| l.to_lowercase().contains("proxy"))
5936                .collect();
5937            if !proxy_lines.is_empty() {
5938                out.push_str("\nSystem proxy (/etc/environment):\n");
5939                for l in proxy_lines {
5940                    out.push_str(&format!("  {l}\n"));
5941                }
5942            }
5943        }
5944    }
5945
5946    Ok(out.trim_end().to_string())
5947}
5948
5949// ── firewall_rules ────────────────────────────────────────────────────────────
5950
5951fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
5952    let mut out = String::from("Host inspection: firewall_rules\n\n");
5953    let n = max_entries.clamp(1, 20);
5954
5955    #[cfg(target_os = "windows")]
5956    {
5957        let script = format!(
5958            r#"
5959try {{
5960    $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
5961        Where-Object {{
5962            $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
5963            $_.Owner -eq $null
5964        }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
5965    "TOTAL:" + $rules.Count
5966    $rules | ForEach-Object {{
5967        $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
5968        $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
5969        $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
5970    }}
5971}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5972        );
5973
5974        let output = Command::new("powershell")
5975            .args(["-NoProfile", "-Command", &script])
5976            .output()
5977            .map_err(|e| format!("firewall_rules: {e}"))?;
5978
5979        let raw = String::from_utf8_lossy(&output.stdout);
5980        let text = raw.trim();
5981
5982        if text.starts_with("ERROR:") {
5983            out.push_str(&format!(
5984                "Unable to query firewall rules: {}\n",
5985                text.trim_start_matches("ERROR:").trim()
5986            ));
5987            out.push_str("This query may require running as administrator.\n");
5988        } else if text.is_empty() {
5989            out.push_str("No non-default enabled firewall rules found.\n");
5990        } else {
5991            let mut total = 0usize;
5992            for line in text.lines() {
5993                if let Some(rest) = line.strip_prefix("TOTAL:") {
5994                    total = rest.trim().parse().unwrap_or(0);
5995                    out.push_str(&format!(
5996                        "Non-default enabled rules (showing up to {n}):\n\n"
5997                    ));
5998                } else {
5999                    let parts: Vec<&str> = line.splitn(4, '|').collect();
6000                    if parts.len() >= 3 {
6001                        let name = parts[0];
6002                        let dir = parts[1];
6003                        let action = parts[2];
6004                        let profile = parts.get(3).unwrap_or(&"Any");
6005                        let icon = if action == "Block" { "[!]" } else { "   " };
6006                        out.push_str(&format!(
6007                            "  {icon} [{dir}] {action}: {name} (profile: {profile})\n"
6008                        ));
6009                    }
6010                }
6011            }
6012            if total == 0 {
6013                out.push_str("No non-default enabled rules found.\n");
6014            }
6015        }
6016    }
6017
6018    #[cfg(not(target_os = "windows"))]
6019    {
6020        if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
6021            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6022            if !text.is_empty() {
6023                out.push_str(&text);
6024                out.push('\n');
6025            }
6026        } else if let Ok(o) = Command::new("iptables")
6027            .args(["-L", "-n", "--line-numbers"])
6028            .output()
6029        {
6030            let text = String::from_utf8_lossy(&o.stdout);
6031            for l in text.lines().take(n * 2) {
6032                out.push_str(&format!("  {l}\n"));
6033            }
6034        } else {
6035            out.push_str("ufw and iptables not available or insufficient permissions.\n");
6036        }
6037    }
6038
6039    Ok(out.trim_end().to_string())
6040}
6041
6042// ── traceroute ────────────────────────────────────────────────────────────────
6043
6044fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
6045    let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
6046    let hops = max_entries.clamp(5, 30);
6047
6048    #[cfg(target_os = "windows")]
6049    {
6050        let output = Command::new("tracert")
6051            .args(["-d", "-h", &hops.to_string(), host])
6052            .output()
6053            .map_err(|e| format!("tracert: {e}"))?;
6054        let raw = String::from_utf8_lossy(&output.stdout);
6055        let mut hop_count = 0usize;
6056        for line in raw.lines() {
6057            let trimmed = line.trim();
6058            if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
6059                hop_count += 1;
6060                out.push_str(&format!("  {trimmed}\n"));
6061            } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
6062                out.push_str(&format!("{trimmed}\n"));
6063            }
6064        }
6065        if hop_count == 0 {
6066            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6067        }
6068    }
6069
6070    #[cfg(not(target_os = "windows"))]
6071    {
6072        let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
6073            || std::path::Path::new("/usr/sbin/traceroute").exists()
6074        {
6075            "traceroute"
6076        } else {
6077            "tracepath"
6078        };
6079        let output = Command::new(cmd)
6080            .args(["-m", &hops.to_string(), "-n", host])
6081            .output()
6082            .map_err(|e| format!("{cmd}: {e}"))?;
6083        let raw = String::from_utf8_lossy(&output.stdout);
6084        let mut hop_count = 0usize;
6085        for line in raw.lines().take(hops + 2) {
6086            let trimmed = line.trim();
6087            if !trimmed.is_empty() {
6088                hop_count += 1;
6089                out.push_str(&format!("  {trimmed}\n"));
6090            }
6091        }
6092        if hop_count == 0 {
6093            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6094        }
6095    }
6096
6097    Ok(out.trim_end().to_string())
6098}
6099
6100// ── dns_cache ─────────────────────────────────────────────────────────────────
6101
6102fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
6103    let mut out = String::from("Host inspection: dns_cache\n\n");
6104    let n = max_entries.clamp(10, 100);
6105
6106    #[cfg(target_os = "windows")]
6107    {
6108        let output = Command::new("powershell")
6109            .args([
6110                "-NoProfile",
6111                "-Command",
6112                "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
6113            ])
6114            .output()
6115            .map_err(|e| format!("dns_cache: {e}"))?;
6116
6117        let raw = String::from_utf8_lossy(&output.stdout);
6118        let lines: Vec<&str> = raw.lines().skip(1).collect();
6119        let total = lines.len();
6120
6121        if total == 0 {
6122            out.push_str("DNS cache is empty or could not be read.\n");
6123        } else {
6124            out.push_str(&format!(
6125                "DNS cache entries (showing up to {n} of {total}):\n\n"
6126            ));
6127            let mut shown = 0usize;
6128            for line in lines.iter().take(n) {
6129                let cols: Vec<&str> = line.splitn(4, ',').collect();
6130                if cols.len() >= 3 {
6131                    let entry = cols[0].trim_matches('"');
6132                    let rtype = cols[1].trim_matches('"');
6133                    let data = cols[2].trim_matches('"');
6134                    let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
6135                    out.push_str(&format!("  {entry:<45} {rtype:<6} {data}  (TTL {ttl}s)\n"));
6136                    shown += 1;
6137                }
6138            }
6139            if total > shown {
6140                out.push_str(&format!("\n  ... and {} more entries\n", total - shown));
6141            }
6142        }
6143    }
6144
6145    #[cfg(not(target_os = "windows"))]
6146    {
6147        if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
6148            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6149            if !text.is_empty() {
6150                out.push_str("systemd-resolved statistics:\n");
6151                for line in text.lines().take(n) {
6152                    out.push_str(&format!("  {line}\n"));
6153                }
6154                out.push('\n');
6155            }
6156        }
6157        if let Ok(o) = Command::new("dscacheutil")
6158            .args(["-cachedump", "-entries", "Host"])
6159            .output()
6160        {
6161            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6162            if !text.is_empty() {
6163                out.push_str("DNS cache (macOS dscacheutil):\n");
6164                for line in text.lines().take(n) {
6165                    out.push_str(&format!("  {line}\n"));
6166                }
6167            } else {
6168                out.push_str("DNS cache is empty or not accessible on this platform.\n");
6169            }
6170        } else {
6171            out.push_str(
6172                "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
6173            );
6174        }
6175    }
6176
6177    Ok(out.trim_end().to_string())
6178}
6179
6180// ── arp ───────────────────────────────────────────────────────────────────────
6181
6182fn inspect_arp() -> Result<String, String> {
6183    let mut out = String::from("Host inspection: arp\n\n");
6184
6185    #[cfg(target_os = "windows")]
6186    {
6187        let output = Command::new("arp")
6188            .args(["-a"])
6189            .output()
6190            .map_err(|e| format!("arp: {e}"))?;
6191        let raw = String::from_utf8_lossy(&output.stdout);
6192        let mut count = 0usize;
6193        for line in raw.lines() {
6194            let t = line.trim();
6195            if t.is_empty() {
6196                continue;
6197            }
6198            out.push_str(&format!("  {t}\n"));
6199            if t.contains("dynamic") || t.contains("static") {
6200                count += 1;
6201            }
6202        }
6203        out.push_str(&format!("\nTotal entries: {count}\n"));
6204    }
6205
6206    #[cfg(not(target_os = "windows"))]
6207    {
6208        if let Ok(o) = Command::new("arp").args(["-n"]).output() {
6209            let raw = String::from_utf8_lossy(&o.stdout);
6210            let mut count = 0usize;
6211            for line in raw.lines() {
6212                let t = line.trim();
6213                if !t.is_empty() {
6214                    out.push_str(&format!("  {t}\n"));
6215                    count += 1;
6216                }
6217            }
6218            out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
6219        } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
6220            let raw = String::from_utf8_lossy(&o.stdout);
6221            let mut count = 0usize;
6222            for line in raw.lines() {
6223                let t = line.trim();
6224                if !t.is_empty() {
6225                    out.push_str(&format!("  {t}\n"));
6226                    count += 1;
6227                }
6228            }
6229            out.push_str(&format!("\nTotal entries: {count}\n"));
6230        } else {
6231            out.push_str("arp and ip neigh not available.\n");
6232        }
6233    }
6234
6235    Ok(out.trim_end().to_string())
6236}
6237
6238// ── route_table ───────────────────────────────────────────────────────────────
6239
6240fn inspect_route_table(max_entries: usize) -> Result<String, String> {
6241    let mut out = String::from("Host inspection: route_table\n\n");
6242    let n = max_entries.clamp(10, 50);
6243
6244    #[cfg(target_os = "windows")]
6245    {
6246        let script = r#"
6247try {
6248    $routes = Get-NetRoute -ErrorAction Stop |
6249        Where-Object { $_.RouteMetric -lt 9000 } |
6250        Sort-Object RouteMetric |
6251        Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
6252    "TOTAL:" + $routes.Count
6253    $routes | ForEach-Object {
6254        $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
6255    }
6256} catch { "ERROR:" + $_.Exception.Message }
6257"#;
6258        let output = Command::new("powershell")
6259            .args(["-NoProfile", "-Command", script])
6260            .output()
6261            .map_err(|e| format!("route_table: {e}"))?;
6262        let raw = String::from_utf8_lossy(&output.stdout);
6263        let text = raw.trim();
6264
6265        if text.starts_with("ERROR:") {
6266            out.push_str(&format!(
6267                "Unable to read route table: {}\n",
6268                text.trim_start_matches("ERROR:").trim()
6269            ));
6270        } else {
6271            let mut shown = 0usize;
6272            for line in text.lines() {
6273                if let Some(rest) = line.strip_prefix("TOTAL:") {
6274                    let total: usize = rest.trim().parse().unwrap_or(0);
6275                    out.push_str(&format!(
6276                        "Routing table (showing up to {n} of {total} routes):\n\n"
6277                    ));
6278                    out.push_str(&format!(
6279                        "  {:<22} {:<18} {:>8}  Interface\n",
6280                        "Destination", "Next Hop", "Metric"
6281                    ));
6282                    out.push_str(&format!("  {}\n", "-".repeat(70)));
6283                } else if shown < n {
6284                    let parts: Vec<&str> = line.splitn(4, '|').collect();
6285                    if parts.len() == 4 {
6286                        let dest = parts[0];
6287                        let hop =
6288                            if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
6289                                "on-link"
6290                            } else {
6291                                parts[1]
6292                            };
6293                        let metric = parts[2];
6294                        let iface = parts[3];
6295                        out.push_str(&format!("  {dest:<22} {hop:<18} {metric:>8}  {iface}\n"));
6296                        shown += 1;
6297                    }
6298                }
6299            }
6300        }
6301    }
6302
6303    #[cfg(not(target_os = "windows"))]
6304    {
6305        if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
6306            let raw = String::from_utf8_lossy(&o.stdout);
6307            let lines: Vec<&str> = raw.lines().collect();
6308            let total = lines.len();
6309            out.push_str(&format!(
6310                "Routing table (showing up to {n} of {total} routes):\n\n"
6311            ));
6312            for line in lines.iter().take(n) {
6313                out.push_str(&format!("  {line}\n"));
6314            }
6315            if total > n {
6316                out.push_str(&format!("\n  ... and {} more routes\n", total - n));
6317            }
6318        } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
6319            let raw = String::from_utf8_lossy(&o.stdout);
6320            for line in raw.lines().take(n) {
6321                out.push_str(&format!("  {line}\n"));
6322            }
6323        } else {
6324            out.push_str("ip route and netstat not available.\n");
6325        }
6326    }
6327
6328    Ok(out.trim_end().to_string())
6329}
6330
6331// ── env ───────────────────────────────────────────────────────────────────────
6332
6333fn inspect_env(max_entries: usize) -> Result<String, String> {
6334    let mut out = String::from("Host inspection: env\n\n");
6335    let n = max_entries.clamp(10, 50);
6336
6337    fn looks_like_secret(name: &str) -> bool {
6338        let n = name.to_uppercase();
6339        n.contains("KEY")
6340            || n.contains("SECRET")
6341            || n.contains("TOKEN")
6342            || n.contains("PASSWORD")
6343            || n.contains("PASSWD")
6344            || n.contains("CREDENTIAL")
6345            || n.contains("AUTH")
6346            || n.contains("CERT")
6347            || n.contains("PRIVATE")
6348    }
6349
6350    let known_dev_vars: &[&str] = &[
6351        "CARGO_HOME",
6352        "RUSTUP_HOME",
6353        "GOPATH",
6354        "GOROOT",
6355        "GOBIN",
6356        "JAVA_HOME",
6357        "ANDROID_HOME",
6358        "ANDROID_SDK_ROOT",
6359        "PYTHONPATH",
6360        "PYTHONHOME",
6361        "VIRTUAL_ENV",
6362        "CONDA_DEFAULT_ENV",
6363        "CONDA_PREFIX",
6364        "NODE_PATH",
6365        "NVM_DIR",
6366        "NVM_BIN",
6367        "PNPM_HOME",
6368        "DENO_INSTALL",
6369        "DENO_DIR",
6370        "DOTNET_ROOT",
6371        "NUGET_PACKAGES",
6372        "CMAKE_HOME",
6373        "VCPKG_ROOT",
6374        "AWS_PROFILE",
6375        "AWS_REGION",
6376        "AWS_DEFAULT_REGION",
6377        "GCP_PROJECT",
6378        "GOOGLE_CLOUD_PROJECT",
6379        "GOOGLE_APPLICATION_CREDENTIALS",
6380        "AZURE_SUBSCRIPTION_ID",
6381        "DATABASE_URL",
6382        "REDIS_URL",
6383        "MONGO_URI",
6384        "EDITOR",
6385        "VISUAL",
6386        "SHELL",
6387        "TERM",
6388        "XDG_CONFIG_HOME",
6389        "XDG_DATA_HOME",
6390        "XDG_CACHE_HOME",
6391        "HOME",
6392        "USERPROFILE",
6393        "APPDATA",
6394        "LOCALAPPDATA",
6395        "TEMP",
6396        "TMP",
6397        "COMPUTERNAME",
6398        "USERNAME",
6399        "USERDOMAIN",
6400        "PROCESSOR_ARCHITECTURE",
6401        "NUMBER_OF_PROCESSORS",
6402        "OS",
6403        "HOMEDRIVE",
6404        "HOMEPATH",
6405        "HTTP_PROXY",
6406        "HTTPS_PROXY",
6407        "NO_PROXY",
6408        "ALL_PROXY",
6409        "http_proxy",
6410        "https_proxy",
6411        "no_proxy",
6412        "DOCKER_HOST",
6413        "DOCKER_BUILDKIT",
6414        "COMPOSE_PROJECT_NAME",
6415        "KUBECONFIG",
6416        "KUBE_CONTEXT",
6417        "CI",
6418        "GITHUB_ACTIONS",
6419        "GITLAB_CI",
6420        "LMSTUDIO_HOME",
6421        "HEMATITE_URL",
6422    ];
6423
6424    let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
6425    all_vars.sort_by(|a, b| a.0.cmp(&b.0));
6426    let total = all_vars.len();
6427
6428    let mut dev_found: Vec<String> = Vec::new();
6429    let mut secret_found: Vec<String> = Vec::new();
6430
6431    for (k, v) in &all_vars {
6432        if k == "PATH" {
6433            continue;
6434        }
6435        if looks_like_secret(k) {
6436            secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
6437        } else {
6438            let k_upper = k.to_uppercase();
6439            let is_known = known_dev_vars
6440                .iter()
6441                .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
6442            if is_known {
6443                let display = if v.len() > 120 {
6444                    format!("{k} = {}…", &v[..117])
6445                } else {
6446                    format!("{k} = {v}")
6447                };
6448                dev_found.push(display);
6449            }
6450        }
6451    }
6452
6453    out.push_str(&format!("Total environment variables: {total}\n\n"));
6454
6455    if let Ok(p) = std::env::var("PATH") {
6456        let sep = if cfg!(target_os = "windows") {
6457            ';'
6458        } else {
6459            ':'
6460        };
6461        let count = p.split(sep).count();
6462        out.push_str(&format!(
6463            "PATH: {count} entries (use topic=path for full audit)\n\n"
6464        ));
6465    }
6466
6467    if !secret_found.is_empty() {
6468        out.push_str(&format!(
6469            "=== Secret/credential variables ({} detected, values hidden) ===\n",
6470            secret_found.len()
6471        ));
6472        for s in secret_found.iter().take(n) {
6473            out.push_str(&format!("  {s}\n"));
6474        }
6475        out.push('\n');
6476    }
6477
6478    if !dev_found.is_empty() {
6479        out.push_str(&format!(
6480            "=== Developer & tool variables ({}) ===\n",
6481            dev_found.len()
6482        ));
6483        for d in dev_found.iter().take(n) {
6484            out.push_str(&format!("  {d}\n"));
6485        }
6486        out.push('\n');
6487    }
6488
6489    let other_count = all_vars
6490        .iter()
6491        .filter(|(k, _)| {
6492            k != "PATH"
6493                && !looks_like_secret(k)
6494                && !known_dev_vars
6495                    .iter()
6496                    .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
6497        })
6498        .count();
6499    if other_count > 0 {
6500        out.push_str(&format!(
6501            "Other variables: {other_count} (use 'env' in shell to see all)\n"
6502        ));
6503    }
6504
6505    Ok(out.trim_end().to_string())
6506}
6507
6508// ── hosts_file ────────────────────────────────────────────────────────────────
6509
6510fn inspect_hosts_file() -> Result<String, String> {
6511    let mut out = String::from("Host inspection: hosts_file\n\n");
6512
6513    let hosts_path = if cfg!(target_os = "windows") {
6514        std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
6515    } else {
6516        std::path::PathBuf::from("/etc/hosts")
6517    };
6518
6519    out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
6520
6521    match fs::read_to_string(&hosts_path) {
6522        Ok(content) => {
6523            let mut active_entries: Vec<String> = Vec::new();
6524            let mut comment_lines = 0usize;
6525            let mut blank_lines = 0usize;
6526
6527            for line in content.lines() {
6528                let t = line.trim();
6529                if t.is_empty() {
6530                    blank_lines += 1;
6531                } else if t.starts_with('#') {
6532                    comment_lines += 1;
6533                } else {
6534                    active_entries.push(line.to_string());
6535                }
6536            }
6537
6538            out.push_str(&format!(
6539                "Active entries: {}  |  Comment lines: {}  |  Blank lines: {}\n\n",
6540                active_entries.len(),
6541                comment_lines,
6542                blank_lines
6543            ));
6544
6545            if active_entries.is_empty() {
6546                out.push_str(
6547                    "No active host entries (file contains only comments/blanks — standard default state).\n",
6548                );
6549            } else {
6550                out.push_str("=== Active entries ===\n");
6551                for entry in &active_entries {
6552                    out.push_str(&format!("  {entry}\n"));
6553                }
6554                out.push('\n');
6555
6556                let custom: Vec<&String> = active_entries
6557                    .iter()
6558                    .filter(|e| {
6559                        let t = e.trim_start();
6560                        !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
6561                    })
6562                    .collect();
6563                if !custom.is_empty() {
6564                    out.push_str(&format!(
6565                        "[!] Custom (non-loopback) entries: {}\n",
6566                        custom.len()
6567                    ));
6568                    for e in &custom {
6569                        out.push_str(&format!("  {e}\n"));
6570                    }
6571                } else {
6572                    out.push_str("All active entries are standard loopback or block entries.\n");
6573                }
6574            }
6575
6576            out.push_str("\n=== Full file ===\n");
6577            for line in content.lines() {
6578                out.push_str(&format!("  {line}\n"));
6579            }
6580        }
6581        Err(e) => {
6582            out.push_str(&format!("Could not read hosts file: {e}\n"));
6583            if cfg!(target_os = "windows") {
6584                out.push_str(
6585                    "On Windows, run Hematite as Administrator if permission is denied.\n",
6586                );
6587            }
6588        }
6589    }
6590
6591    Ok(out.trim_end().to_string())
6592}
6593
6594// ── docker ────────────────────────────────────────────────────────────────────
6595
6596fn inspect_docker(max_entries: usize) -> Result<String, String> {
6597    let mut out = String::from("Host inspection: docker\n\n");
6598    let n = max_entries.clamp(5, 25);
6599
6600    let version_output = Command::new("docker")
6601        .args(["version", "--format", "{{.Server.Version}}"])
6602        .output();
6603
6604    match version_output {
6605        Err(_) => {
6606            out.push_str("Docker: not found on PATH.\n");
6607            out.push_str(
6608                "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
6609            );
6610            return Ok(out.trim_end().to_string());
6611        }
6612        Ok(o) if !o.status.success() => {
6613            let stderr = String::from_utf8_lossy(&o.stderr);
6614            if stderr.contains("cannot connect")
6615                || stderr.contains("Is the docker daemon running")
6616                || stderr.contains("pipe")
6617                || stderr.contains("socket")
6618            {
6619                out.push_str("Docker: installed but daemon is NOT running.\n");
6620                out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
6621            } else {
6622                out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
6623            }
6624            return Ok(out.trim_end().to_string());
6625        }
6626        Ok(o) => {
6627            let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
6628            out.push_str(&format!("Docker Engine: {version}\n"));
6629        }
6630    }
6631
6632    if let Ok(o) = Command::new("docker")
6633        .args([
6634            "info",
6635            "--format",
6636            "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
6637        ])
6638        .output()
6639    {
6640        let info = String::from_utf8_lossy(&o.stdout);
6641        for line in info.lines() {
6642            let t = line.trim();
6643            if !t.is_empty() {
6644                out.push_str(&format!("  {t}\n"));
6645            }
6646        }
6647        out.push('\n');
6648    }
6649
6650    if let Ok(o) = Command::new("docker")
6651        .args([
6652            "ps",
6653            "--format",
6654            "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
6655        ])
6656        .output()
6657    {
6658        let raw = String::from_utf8_lossy(&o.stdout);
6659        let lines: Vec<&str> = raw.lines().collect();
6660        if lines.len() <= 1 {
6661            out.push_str("Running containers: none\n\n");
6662        } else {
6663            out.push_str(&format!(
6664                "=== Running containers ({}) ===\n",
6665                lines.len().saturating_sub(1)
6666            ));
6667            for line in lines.iter().take(n + 1) {
6668                out.push_str(&format!("  {line}\n"));
6669            }
6670            if lines.len() > n + 1 {
6671                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
6672            }
6673            out.push('\n');
6674        }
6675    }
6676
6677    if let Ok(o) = Command::new("docker")
6678        .args([
6679            "images",
6680            "--format",
6681            "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
6682        ])
6683        .output()
6684    {
6685        let raw = String::from_utf8_lossy(&o.stdout);
6686        let lines: Vec<&str> = raw.lines().collect();
6687        if lines.len() > 1 {
6688            out.push_str(&format!(
6689                "=== Local images ({}) ===\n",
6690                lines.len().saturating_sub(1)
6691            ));
6692            for line in lines.iter().take(n + 1) {
6693                out.push_str(&format!("  {line}\n"));
6694            }
6695            if lines.len() > n + 1 {
6696                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
6697            }
6698            out.push('\n');
6699        }
6700    }
6701
6702    if let Ok(o) = Command::new("docker")
6703        .args([
6704            "compose",
6705            "ls",
6706            "--format",
6707            "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
6708        ])
6709        .output()
6710    {
6711        let raw = String::from_utf8_lossy(&o.stdout);
6712        let lines: Vec<&str> = raw.lines().collect();
6713        if lines.len() > 1 {
6714            out.push_str(&format!(
6715                "=== Compose projects ({}) ===\n",
6716                lines.len().saturating_sub(1)
6717            ));
6718            for line in lines.iter().take(n + 1) {
6719                out.push_str(&format!("  {line}\n"));
6720            }
6721            out.push('\n');
6722        }
6723    }
6724
6725    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
6726        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
6727        if !ctx.is_empty() {
6728            out.push_str(&format!("Active context: {ctx}\n"));
6729        }
6730    }
6731
6732    Ok(out.trim_end().to_string())
6733}
6734
6735// ── wsl ───────────────────────────────────────────────────────────────────────
6736
6737fn inspect_wsl() -> Result<String, String> {
6738    let mut out = String::from("Host inspection: wsl\n\n");
6739
6740    #[cfg(target_os = "windows")]
6741    {
6742        if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
6743            let raw = String::from_utf8_lossy(&o.stdout);
6744            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6745            for line in cleaned.lines().take(4) {
6746                let t = line.trim();
6747                if !t.is_empty() {
6748                    out.push_str(&format!("  {t}\n"));
6749                }
6750            }
6751            out.push('\n');
6752        }
6753
6754        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
6755        match list_output {
6756            Err(e) => {
6757                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
6758                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
6759            }
6760            Ok(o) if !o.status.success() => {
6761                let stderr = String::from_utf8_lossy(&o.stderr);
6762                let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
6763                out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
6764                out.push_str("Run: wsl --install\n");
6765            }
6766            Ok(o) => {
6767                let raw = String::from_utf8_lossy(&o.stdout);
6768                let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6769                let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
6770                let distro_lines: Vec<&str> = lines
6771                    .iter()
6772                    .filter(|l| {
6773                        let t = l.trim();
6774                        !t.is_empty()
6775                            && !t.to_uppercase().starts_with("NAME")
6776                            && !t.starts_with("---")
6777                    })
6778                    .copied()
6779                    .collect();
6780
6781                if distro_lines.is_empty() {
6782                    out.push_str("WSL: installed but no distributions found.\n");
6783                    out.push_str("Install a distro: wsl --install -d Ubuntu\n");
6784                } else {
6785                    out.push_str("=== WSL Distributions ===\n");
6786                    for line in &lines {
6787                        out.push_str(&format!("  {}\n", line.trim()));
6788                    }
6789                    out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
6790                }
6791            }
6792        }
6793
6794        if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
6795            let raw = String::from_utf8_lossy(&o.stdout);
6796            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6797            let status_lines: Vec<&str> = cleaned
6798                .lines()
6799                .filter(|l| !l.trim().is_empty())
6800                .take(8)
6801                .collect();
6802            if !status_lines.is_empty() {
6803                out.push_str("\n=== WSL status ===\n");
6804                for line in status_lines {
6805                    out.push_str(&format!("  {}\n", line.trim()));
6806                }
6807            }
6808        }
6809    }
6810
6811    #[cfg(not(target_os = "windows"))]
6812    {
6813        out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
6814        out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
6815    }
6816
6817    Ok(out.trim_end().to_string())
6818}
6819
6820// ── ssh ───────────────────────────────────────────────────────────────────────
6821
6822fn dirs_home() -> Option<PathBuf> {
6823    std::env::var("HOME")
6824        .ok()
6825        .map(PathBuf::from)
6826        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
6827}
6828
6829fn inspect_ssh() -> Result<String, String> {
6830    let mut out = String::from("Host inspection: ssh\n\n");
6831
6832    if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
6833        let ver = if o.stdout.is_empty() {
6834            String::from_utf8_lossy(&o.stderr).trim().to_string()
6835        } else {
6836            String::from_utf8_lossy(&o.stdout).trim().to_string()
6837        };
6838        if !ver.is_empty() {
6839            out.push_str(&format!("SSH client: {ver}\n"));
6840        }
6841    } else {
6842        out.push_str("SSH client: not found on PATH.\n");
6843    }
6844
6845    #[cfg(target_os = "windows")]
6846    {
6847        let script = r#"
6848$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
6849if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
6850else { "SSHD:not_installed" }
6851"#;
6852        if let Ok(o) = Command::new("powershell")
6853            .args(["-NoProfile", "-Command", script])
6854            .output()
6855        {
6856            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6857            if text.contains("not_installed") {
6858                out.push_str("SSH server (sshd): not installed\n");
6859            } else {
6860                out.push_str(&format!(
6861                    "SSH server (sshd): {}\n",
6862                    text.trim_start_matches("SSHD:")
6863                ));
6864            }
6865        }
6866    }
6867
6868    #[cfg(not(target_os = "windows"))]
6869    {
6870        if let Ok(o) = Command::new("systemctl")
6871            .args(["is-active", "sshd"])
6872            .output()
6873        {
6874            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
6875            out.push_str(&format!("SSH server (sshd): {status}\n"));
6876        } else if let Ok(o) = Command::new("systemctl")
6877            .args(["is-active", "ssh"])
6878            .output()
6879        {
6880            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
6881            out.push_str(&format!("SSH server (ssh): {status}\n"));
6882        }
6883    }
6884
6885    out.push('\n');
6886
6887    if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
6888        if ssh_dir.exists() {
6889            out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
6890
6891            let kh = ssh_dir.join("known_hosts");
6892            if kh.exists() {
6893                let count = fs::read_to_string(&kh)
6894                    .map(|c| {
6895                        c.lines()
6896                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
6897                            .count()
6898                    })
6899                    .unwrap_or(0);
6900                out.push_str(&format!("  known_hosts: {count} entries\n"));
6901            } else {
6902                out.push_str("  known_hosts: not present\n");
6903            }
6904
6905            let ak = ssh_dir.join("authorized_keys");
6906            if ak.exists() {
6907                let count = fs::read_to_string(&ak)
6908                    .map(|c| {
6909                        c.lines()
6910                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
6911                            .count()
6912                    })
6913                    .unwrap_or(0);
6914                out.push_str(&format!("  authorized_keys: {count} public keys\n"));
6915            } else {
6916                out.push_str("  authorized_keys: not present\n");
6917            }
6918
6919            let key_names = [
6920                "id_rsa",
6921                "id_ed25519",
6922                "id_ecdsa",
6923                "id_dsa",
6924                "id_ecdsa_sk",
6925                "id_ed25519_sk",
6926            ];
6927            let found_keys: Vec<&str> = key_names
6928                .iter()
6929                .filter(|k| ssh_dir.join(k).exists())
6930                .copied()
6931                .collect();
6932            if !found_keys.is_empty() {
6933                out.push_str(&format!("  Private keys: {}\n", found_keys.join(", ")));
6934            } else {
6935                out.push_str("  Private keys: none found\n");
6936            }
6937
6938            let config_path = ssh_dir.join("config");
6939            if config_path.exists() {
6940                out.push_str("\n=== SSH config hosts ===\n");
6941                match fs::read_to_string(&config_path) {
6942                    Ok(content) => {
6943                        let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
6944                        let mut current: Option<(String, Vec<String>)> = None;
6945                        for line in content.lines() {
6946                            let t = line.trim();
6947                            if t.is_empty() || t.starts_with('#') {
6948                                continue;
6949                            }
6950                            if let Some(host) = t.strip_prefix("Host ") {
6951                                if let Some(prev) = current.take() {
6952                                    hosts.push(prev);
6953                                }
6954                                current = Some((host.trim().to_string(), Vec::new()));
6955                            } else if let Some((_, ref mut details)) = current {
6956                                let tu = t.to_uppercase();
6957                                if tu.starts_with("HOSTNAME ")
6958                                    || tu.starts_with("USER ")
6959                                    || tu.starts_with("PORT ")
6960                                    || tu.starts_with("IDENTITYFILE ")
6961                                {
6962                                    details.push(t.to_string());
6963                                }
6964                            }
6965                        }
6966                        if let Some(prev) = current {
6967                            hosts.push(prev);
6968                        }
6969
6970                        if hosts.is_empty() {
6971                            out.push_str("  No Host entries found.\n");
6972                        } else {
6973                            for (h, details) in &hosts {
6974                                if details.is_empty() {
6975                                    out.push_str(&format!("  Host {h}\n"));
6976                                } else {
6977                                    out.push_str(&format!(
6978                                        "  Host {h}  [{}]\n",
6979                                        details.join(", ")
6980                                    ));
6981                                }
6982                            }
6983                            out.push_str(&format!("\n  Total configured hosts: {}\n", hosts.len()));
6984                        }
6985                    }
6986                    Err(e) => out.push_str(&format!("  Could not read config: {e}\n")),
6987                }
6988            } else {
6989                out.push_str("  SSH config: not present\n");
6990            }
6991        } else {
6992            out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
6993        }
6994    }
6995
6996    Ok(out.trim_end().to_string())
6997}
6998
6999// ── installed_software ────────────────────────────────────────────────────────
7000
7001fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
7002    let mut out = String::from("Host inspection: installed_software\n\n");
7003    let n = max_entries.clamp(10, 50);
7004
7005    #[cfg(target_os = "windows")]
7006    {
7007        let winget_out = Command::new("winget")
7008            .args(["list", "--accept-source-agreements"])
7009            .output();
7010
7011        if let Ok(o) = winget_out {
7012            if o.status.success() {
7013                let raw = String::from_utf8_lossy(&o.stdout);
7014                let mut header_done = false;
7015                let mut packages: Vec<&str> = Vec::new();
7016                for line in raw.lines() {
7017                    let t = line.trim();
7018                    if t.starts_with("---") {
7019                        header_done = true;
7020                        continue;
7021                    }
7022                    if header_done && !t.is_empty() {
7023                        packages.push(line);
7024                    }
7025                }
7026                let total = packages.len();
7027                out.push_str(&format!(
7028                    "=== Installed software via winget ({total} packages) ===\n\n"
7029                ));
7030                for line in packages.iter().take(n) {
7031                    out.push_str(&format!("  {line}\n"));
7032                }
7033                if total > n {
7034                    out.push_str(&format!("\n  ... and {} more packages\n", total - n));
7035                }
7036                out.push_str("\nFor full list: winget list\n");
7037                return Ok(out.trim_end().to_string());
7038            }
7039        }
7040
7041        // Fallback: registry scan
7042        let script = format!(
7043            r#"
7044$apps = @()
7045$reg_paths = @(
7046    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
7047    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
7048    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
7049)
7050foreach ($p in $reg_paths) {{
7051    try {{
7052        $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
7053            Where-Object {{ $_.DisplayName }} |
7054            Select-Object DisplayName, DisplayVersion, Publisher
7055    }} catch {{}}
7056}}
7057$sorted = $apps | Sort-Object DisplayName -Unique
7058"TOTAL:" + $sorted.Count
7059$sorted | Select-Object -First {n} | ForEach-Object {{
7060    $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
7061}}
7062"#
7063        );
7064        if let Ok(o) = Command::new("powershell")
7065            .args(["-NoProfile", "-Command", &script])
7066            .output()
7067        {
7068            let raw = String::from_utf8_lossy(&o.stdout);
7069            out.push_str("=== Installed software (registry scan) ===\n");
7070            out.push_str(&format!("  {:<50} {:<18} Publisher\n", "Name", "Version"));
7071            out.push_str(&format!("  {}\n", "-".repeat(90)));
7072            for line in raw.lines() {
7073                if let Some(rest) = line.strip_prefix("TOTAL:") {
7074                    let total: usize = rest.trim().parse().unwrap_or(0);
7075                    out.push_str(&format!("  (Total: {total}, showing first {n})\n\n"));
7076                } else if !line.trim().is_empty() {
7077                    let parts: Vec<&str> = line.splitn(3, '|').collect();
7078                    let name = parts.first().map(|s| s.trim()).unwrap_or("");
7079                    let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
7080                    let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
7081                    out.push_str(&format!("  {:<50} {:<18} {pub_}\n", name, ver));
7082                }
7083            }
7084        } else {
7085            out.push_str(
7086                "Could not query installed software (winget and registry scan both failed).\n",
7087            );
7088        }
7089    }
7090
7091    #[cfg(target_os = "linux")]
7092    {
7093        let mut found = false;
7094        if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
7095            if o.status.success() {
7096                let raw = String::from_utf8_lossy(&o.stdout);
7097                let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
7098                let total = installed.len();
7099                out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
7100                for line in installed.iter().take(n) {
7101                    out.push_str(&format!("  {}\n", line.trim()));
7102                }
7103                if total > n {
7104                    out.push_str(&format!("  ... and {} more\n", total - n));
7105                }
7106                out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
7107                found = true;
7108            }
7109        }
7110        if !found {
7111            if let Ok(o) = Command::new("rpm")
7112                .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
7113                .output()
7114            {
7115                if o.status.success() {
7116                    let raw = String::from_utf8_lossy(&o.stdout);
7117                    let lines: Vec<&str> = raw.lines().collect();
7118                    let total = lines.len();
7119                    out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
7120                    for line in lines.iter().take(n) {
7121                        out.push_str(&format!("  {line}\n"));
7122                    }
7123                    if total > n {
7124                        out.push_str(&format!("  ... and {} more\n", total - n));
7125                    }
7126                    found = true;
7127                }
7128            }
7129        }
7130        if !found {
7131            if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
7132                if o.status.success() {
7133                    let raw = String::from_utf8_lossy(&o.stdout);
7134                    let lines: Vec<&str> = raw.lines().collect();
7135                    let total = lines.len();
7136                    out.push_str(&format!(
7137                        "=== Installed packages via pacman ({total}) ===\n"
7138                    ));
7139                    for line in lines.iter().take(n) {
7140                        out.push_str(&format!("  {line}\n"));
7141                    }
7142                    if total > n {
7143                        out.push_str(&format!("  ... and {} more\n", total - n));
7144                    }
7145                    found = true;
7146                }
7147            }
7148        }
7149        if !found {
7150            out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
7151        }
7152    }
7153
7154    #[cfg(target_os = "macos")]
7155    {
7156        if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
7157            if o.status.success() {
7158                let raw = String::from_utf8_lossy(&o.stdout);
7159                let lines: Vec<&str> = raw.lines().collect();
7160                let total = lines.len();
7161                out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
7162                for line in lines.iter().take(n) {
7163                    out.push_str(&format!("  {line}\n"));
7164                }
7165                if total > n {
7166                    out.push_str(&format!("  ... and {} more\n", total - n));
7167                }
7168                out.push_str("\nFor full list: brew list --versions\n");
7169            }
7170        } else {
7171            out.push_str("Homebrew not found.\n");
7172        }
7173        if let Ok(o) = Command::new("mas").args(["list"]).output() {
7174            if o.status.success() {
7175                let raw = String::from_utf8_lossy(&o.stdout);
7176                let lines: Vec<&str> = raw.lines().collect();
7177                out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
7178                for line in lines.iter().take(n) {
7179                    out.push_str(&format!("  {line}\n"));
7180                }
7181            }
7182        }
7183    }
7184
7185    Ok(out.trim_end().to_string())
7186}
7187
7188// ── git_config ────────────────────────────────────────────────────────────────
7189
7190fn inspect_git_config() -> Result<String, String> {
7191    let mut out = String::from("Host inspection: git_config\n\n");
7192
7193    if let Ok(o) = Command::new("git").args(["--version"]).output() {
7194        let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
7195        out.push_str(&format!("Git: {ver}\n\n"));
7196    } else {
7197        out.push_str("Git: not found on PATH.\n");
7198        return Ok(out.trim_end().to_string());
7199    }
7200
7201    if let Ok(o) = Command::new("git")
7202        .args(["config", "--global", "--list"])
7203        .output()
7204    {
7205        if o.status.success() {
7206            let raw = String::from_utf8_lossy(&o.stdout);
7207            let mut pairs: Vec<(String, String)> = raw
7208                .lines()
7209                .filter_map(|l| {
7210                    let mut parts = l.splitn(2, '=');
7211                    let k = parts.next()?.trim().to_string();
7212                    let v = parts.next().unwrap_or("").trim().to_string();
7213                    Some((k, v))
7214                })
7215                .collect();
7216            pairs.sort_by(|a, b| a.0.cmp(&b.0));
7217
7218            out.push_str("=== Global git config ===\n");
7219
7220            let sections: &[(&str, &[&str])] = &[
7221                ("Identity", &["user.name", "user.email", "user.signingkey"]),
7222                (
7223                    "Core",
7224                    &[
7225                        "core.editor",
7226                        "core.autocrlf",
7227                        "core.eol",
7228                        "core.ignorecase",
7229                        "core.filemode",
7230                    ],
7231                ),
7232                (
7233                    "Commit/Signing",
7234                    &[
7235                        "commit.gpgsign",
7236                        "tag.gpgsign",
7237                        "gpg.format",
7238                        "gpg.ssh.allowedsignersfile",
7239                    ],
7240                ),
7241                (
7242                    "Push/Pull",
7243                    &[
7244                        "push.default",
7245                        "push.autosetupremote",
7246                        "pull.rebase",
7247                        "pull.ff",
7248                    ],
7249                ),
7250                ("Credential", &["credential.helper"]),
7251                ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
7252            ];
7253
7254            let mut shown_keys: HashSet<String> = HashSet::new();
7255            for (section, keys) in sections {
7256                let mut section_lines: Vec<String> = Vec::new();
7257                for key in *keys {
7258                    if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
7259                        section_lines.push(format!("  {k} = {v}"));
7260                        shown_keys.insert(k.clone());
7261                    }
7262                }
7263                if !section_lines.is_empty() {
7264                    out.push_str(&format!("\n[{section}]\n"));
7265                    for line in section_lines {
7266                        out.push_str(&format!("{line}\n"));
7267                    }
7268                }
7269            }
7270
7271            let other: Vec<&(String, String)> = pairs
7272                .iter()
7273                .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
7274                .collect();
7275            if !other.is_empty() {
7276                out.push_str("\n[Other]\n");
7277                for (k, v) in other.iter().take(20) {
7278                    out.push_str(&format!("  {k} = {v}\n"));
7279                }
7280                if other.len() > 20 {
7281                    out.push_str(&format!("  ... and {} more\n", other.len() - 20));
7282                }
7283            }
7284
7285            out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
7286        } else {
7287            out.push_str("No global git config found.\n");
7288            out.push_str("Set up with:\n");
7289            out.push_str("  git config --global user.name \"Your Name\"\n");
7290            out.push_str("  git config --global user.email \"you@example.com\"\n");
7291        }
7292    }
7293
7294    if let Ok(o) = Command::new("git")
7295        .args(["config", "--local", "--list"])
7296        .output()
7297    {
7298        if o.status.success() {
7299            let raw = String::from_utf8_lossy(&o.stdout);
7300            let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
7301            if !lines.is_empty() {
7302                out.push_str(&format!(
7303                    "\n=== Local repo config ({} keys) ===\n",
7304                    lines.len()
7305                ));
7306                for line in lines.iter().take(15) {
7307                    out.push_str(&format!("  {line}\n"));
7308                }
7309                if lines.len() > 15 {
7310                    out.push_str(&format!("  ... and {} more\n", lines.len() - 15));
7311                }
7312            }
7313        }
7314    }
7315
7316    if let Ok(o) = Command::new("git")
7317        .args(["config", "--global", "--get-regexp", r"alias\."])
7318        .output()
7319    {
7320        if o.status.success() {
7321            let raw = String::from_utf8_lossy(&o.stdout);
7322            let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
7323            if !aliases.is_empty() {
7324                out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
7325                for a in aliases.iter().take(20) {
7326                    out.push_str(&format!("  {a}\n"));
7327                }
7328                if aliases.len() > 20 {
7329                    out.push_str(&format!("  ... and {} more\n", aliases.len() - 20));
7330                }
7331            }
7332        }
7333    }
7334
7335    Ok(out.trim_end().to_string())
7336}
7337
7338// ── databases ─────────────────────────────────────────────────────────────────
7339
7340fn inspect_databases() -> Result<String, String> {
7341    let mut out = String::from("Host inspection: databases\n\n");
7342    out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
7343
7344    struct DbEngine {
7345        name: &'static str,
7346        service_names: &'static [&'static str],
7347        default_port: u16,
7348        cli_name: &'static str,
7349        cli_version_args: &'static [&'static str],
7350    }
7351
7352    let engines: &[DbEngine] = &[
7353        DbEngine {
7354            name: "PostgreSQL",
7355            service_names: &[
7356                "postgresql",
7357                "postgresql-x64-14",
7358                "postgresql-x64-15",
7359                "postgresql-x64-16",
7360                "postgresql-x64-17",
7361            ],
7362
7363            default_port: 5432,
7364            cli_name: "psql",
7365            cli_version_args: &["--version"],
7366        },
7367        DbEngine {
7368            name: "MySQL",
7369            service_names: &["mysql", "mysql80", "mysql57"],
7370
7371            default_port: 3306,
7372            cli_name: "mysql",
7373            cli_version_args: &["--version"],
7374        },
7375        DbEngine {
7376            name: "MariaDB",
7377            service_names: &["mariadb", "mariadb.exe"],
7378
7379            default_port: 3306,
7380            cli_name: "mariadb",
7381            cli_version_args: &["--version"],
7382        },
7383        DbEngine {
7384            name: "MongoDB",
7385            service_names: &["mongodb", "mongod"],
7386
7387            default_port: 27017,
7388            cli_name: "mongod",
7389            cli_version_args: &["--version"],
7390        },
7391        DbEngine {
7392            name: "Redis",
7393            service_names: &["redis", "redis-server"],
7394
7395            default_port: 6379,
7396            cli_name: "redis-server",
7397            cli_version_args: &["--version"],
7398        },
7399        DbEngine {
7400            name: "SQL Server",
7401            service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
7402
7403            default_port: 1433,
7404            cli_name: "sqlcmd",
7405            cli_version_args: &["-?"],
7406        },
7407        DbEngine {
7408            name: "SQLite",
7409            service_names: &[], // no service — file-based
7410
7411            default_port: 0, // no port — file-based
7412            cli_name: "sqlite3",
7413            cli_version_args: &["--version"],
7414        },
7415        DbEngine {
7416            name: "CouchDB",
7417            service_names: &["couchdb", "apache-couchdb"],
7418
7419            default_port: 5984,
7420            cli_name: "couchdb",
7421            cli_version_args: &["--version"],
7422        },
7423        DbEngine {
7424            name: "Cassandra",
7425            service_names: &["cassandra"],
7426
7427            default_port: 9042,
7428            cli_name: "cqlsh",
7429            cli_version_args: &["--version"],
7430        },
7431        DbEngine {
7432            name: "Elasticsearch",
7433            service_names: &["elasticsearch-service-x64", "elasticsearch"],
7434
7435            default_port: 9200,
7436            cli_name: "elasticsearch",
7437            cli_version_args: &["--version"],
7438        },
7439    ];
7440
7441    // Helper: check if port is listening
7442    fn port_listening(port: u16) -> bool {
7443        if port == 0 {
7444            return false;
7445        }
7446        // Use netstat-style check via connecting
7447        std::net::TcpStream::connect_timeout(
7448            &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7449            std::time::Duration::from_millis(150),
7450        )
7451        .is_ok()
7452    }
7453
7454    let mut found_any = false;
7455
7456    for engine in engines {
7457        let mut status_parts: Vec<String> = Vec::new();
7458        let mut detected = false;
7459
7460        // 1. CLI version check (fastest — works cross-platform)
7461        let version = Command::new(engine.cli_name)
7462            .args(engine.cli_version_args)
7463            .output()
7464            .ok()
7465            .and_then(|o| {
7466                let combined = if o.stdout.is_empty() {
7467                    String::from_utf8_lossy(&o.stderr).trim().to_string()
7468                } else {
7469                    String::from_utf8_lossy(&o.stdout).trim().to_string()
7470                };
7471                // Take just the first line
7472                combined.lines().next().map(|l| l.trim().to_string())
7473            });
7474
7475        if let Some(ref ver) = version {
7476            if !ver.is_empty() {
7477                status_parts.push(format!("version: {ver}"));
7478                detected = true;
7479            }
7480        }
7481
7482        // 2. Port check
7483        if engine.default_port > 0 && port_listening(engine.default_port) {
7484            status_parts.push(format!("listening on :{}", engine.default_port));
7485            detected = true;
7486        } else if engine.default_port > 0 && detected {
7487            status_parts.push(format!("not listening on :{}", engine.default_port));
7488        }
7489
7490        // 3. Windows service check
7491        #[cfg(target_os = "windows")]
7492        {
7493            if !engine.service_names.is_empty() {
7494                let service_list = engine.service_names.join("','");
7495                let script = format!(
7496                    r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
7497                    service_list
7498                );
7499                if let Ok(o) = Command::new("powershell")
7500                    .args(["-NoProfile", "-Command", &script])
7501                    .output()
7502                {
7503                    let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7504                    if !text.is_empty() {
7505                        let parts: Vec<&str> = text.splitn(2, ':').collect();
7506                        let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
7507                        let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
7508                        status_parts.push(format!("service '{svc_name}': {svc_state}"));
7509                        detected = true;
7510                    }
7511                }
7512            }
7513        }
7514
7515        // 4. Linux/macOS systemctl / launchctl check
7516        #[cfg(not(target_os = "windows"))]
7517        {
7518            for svc in engine.service_names {
7519                if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
7520                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
7521                    if !state.is_empty() && state != "inactive" {
7522                        status_parts.push(format!("systemd '{svc}': {state}"));
7523                        detected = true;
7524                        break;
7525                    }
7526                }
7527            }
7528        }
7529
7530        if detected {
7531            found_any = true;
7532            let label = if engine.default_port > 0 {
7533                format!("{} (default port: {})", engine.name, engine.default_port)
7534            } else {
7535                format!("{} (file-based, no port)", engine.name)
7536            };
7537            out.push_str(&format!("[FOUND] {label}\n"));
7538            for part in &status_parts {
7539                out.push_str(&format!("  {part}\n"));
7540            }
7541            out.push('\n');
7542        }
7543    }
7544
7545    if !found_any {
7546        out.push_str("No local database engines detected.\n");
7547        out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
7548        out.push_str(
7549            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
7550        );
7551    } else {
7552        out.push_str("---\n");
7553        out.push_str(
7554            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
7555        );
7556        out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
7557    }
7558
7559    Ok(out.trim_end().to_string())
7560}
7561
7562// ── user_accounts ─────────────────────────────────────────────────────────────
7563
7564fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
7565    let mut out = String::from("Host inspection: user_accounts\n\n");
7566
7567    #[cfg(target_os = "windows")]
7568    {
7569        let users_out = Command::new("powershell")
7570            .args([
7571                "-NoProfile", "-NonInteractive", "-Command",
7572                "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \"  $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
7573            ])
7574            .output()
7575            .ok()
7576            .and_then(|o| String::from_utf8(o.stdout).ok())
7577            .unwrap_or_default();
7578
7579        out.push_str("=== Local User Accounts ===\n");
7580        if users_out.trim().is_empty() {
7581            out.push_str("  (requires elevation or Get-LocalUser unavailable)\n");
7582        } else {
7583            for line in users_out.lines().take(max_entries) {
7584                if !line.trim().is_empty() {
7585                    out.push_str(line);
7586                    out.push('\n');
7587                }
7588            }
7589        }
7590
7591        let admins_out = Command::new("powershell")
7592            .args([
7593                "-NoProfile", "-NonInteractive", "-Command",
7594                "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \"  $($_.ObjectClass): $($_.Name)\" }",
7595            ])
7596            .output()
7597            .ok()
7598            .and_then(|o| String::from_utf8(o.stdout).ok())
7599            .unwrap_or_default();
7600
7601        out.push_str("\n=== Administrators Group Members ===\n");
7602        if admins_out.trim().is_empty() {
7603            out.push_str("  (unable to retrieve)\n");
7604        } else {
7605            out.push_str(admins_out.trim());
7606            out.push('\n');
7607        }
7608
7609        let sessions_out = Command::new("powershell")
7610            .args([
7611                "-NoProfile",
7612                "-NonInteractive",
7613                "-Command",
7614                "query user 2>$null",
7615            ])
7616            .output()
7617            .ok()
7618            .and_then(|o| String::from_utf8(o.stdout).ok())
7619            .unwrap_or_default();
7620
7621        out.push_str("\n=== Active Logon Sessions ===\n");
7622        if sessions_out.trim().is_empty() {
7623            out.push_str("  (none or requires elevation)\n");
7624        } else {
7625            for line in sessions_out.lines().take(max_entries) {
7626                if !line.trim().is_empty() {
7627                    out.push_str(&format!("  {}\n", line));
7628                }
7629            }
7630        }
7631
7632        let is_admin = Command::new("powershell")
7633            .args([
7634                "-NoProfile", "-NonInteractive", "-Command",
7635                "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
7636            ])
7637            .output()
7638            .ok()
7639            .and_then(|o| String::from_utf8(o.stdout).ok())
7640            .map(|s| s.trim().to_lowercase())
7641            .unwrap_or_default();
7642
7643        out.push_str("\n=== Current Session Elevation ===\n");
7644        out.push_str(&format!(
7645            "  Running as Administrator: {}\n",
7646            if is_admin.contains("true") {
7647                "YES"
7648            } else {
7649                "no"
7650            }
7651        ));
7652    }
7653
7654    #[cfg(not(target_os = "windows"))]
7655    {
7656        let who_out = Command::new("who")
7657            .output()
7658            .ok()
7659            .and_then(|o| String::from_utf8(o.stdout).ok())
7660            .unwrap_or_default();
7661        out.push_str("=== Active Sessions ===\n");
7662        if who_out.trim().is_empty() {
7663            out.push_str("  (none)\n");
7664        } else {
7665            for line in who_out.lines().take(max_entries) {
7666                out.push_str(&format!("  {}\n", line));
7667            }
7668        }
7669        let id_out = Command::new("id")
7670            .output()
7671            .ok()
7672            .and_then(|o| String::from_utf8(o.stdout).ok())
7673            .unwrap_or_default();
7674        out.push_str(&format!("\n=== Current User ===\n  {}\n", id_out.trim()));
7675    }
7676
7677    Ok(out.trim_end().to_string())
7678}
7679
7680// ── audit_policy ──────────────────────────────────────────────────────────────
7681
7682fn inspect_audit_policy() -> Result<String, String> {
7683    let mut out = String::from("Host inspection: audit_policy\n\n");
7684
7685    #[cfg(target_os = "windows")]
7686    {
7687        let auditpol_out = Command::new("auditpol")
7688            .args(["/get", "/category:*"])
7689            .output()
7690            .ok()
7691            .and_then(|o| String::from_utf8(o.stdout).ok())
7692            .unwrap_or_default();
7693
7694        if auditpol_out.trim().is_empty()
7695            || auditpol_out.to_lowercase().contains("access is denied")
7696        {
7697            out.push_str("Audit policy requires Administrator elevation to read.\n");
7698            out.push_str(
7699                "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
7700            );
7701        } else {
7702            out.push_str("=== Windows Audit Policy ===\n");
7703            let mut any_enabled = false;
7704            for line in auditpol_out.lines() {
7705                let trimmed = line.trim();
7706                if trimmed.is_empty() {
7707                    continue;
7708                }
7709                if trimmed.contains("Success") || trimmed.contains("Failure") {
7710                    out.push_str(&format!("  [ENABLED] {}\n", trimmed));
7711                    any_enabled = true;
7712                } else {
7713                    out.push_str(&format!("  {}\n", trimmed));
7714                }
7715            }
7716            if !any_enabled {
7717                out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
7718                out.push_str(
7719                    "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
7720                );
7721            }
7722        }
7723
7724        let evtlog = Command::new("powershell")
7725            .args([
7726                "-NoProfile", "-NonInteractive", "-Command",
7727                "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
7728            ])
7729            .output()
7730            .ok()
7731            .and_then(|o| String::from_utf8(o.stdout).ok())
7732            .map(|s| s.trim().to_string())
7733            .unwrap_or_default();
7734
7735        out.push_str(&format!(
7736            "\n=== Windows Event Log Service ===\n  Status: {}\n",
7737            if evtlog.is_empty() {
7738                "unknown".to_string()
7739            } else {
7740                evtlog
7741            }
7742        ));
7743    }
7744
7745    #[cfg(not(target_os = "windows"))]
7746    {
7747        let auditd_status = Command::new("systemctl")
7748            .args(["is-active", "auditd"])
7749            .output()
7750            .ok()
7751            .and_then(|o| String::from_utf8(o.stdout).ok())
7752            .map(|s| s.trim().to_string())
7753            .unwrap_or_else(|| "not found".to_string());
7754
7755        out.push_str(&format!(
7756            "=== auditd service ===\n  Status: {}\n",
7757            auditd_status
7758        ));
7759
7760        if auditd_status == "active" {
7761            let rules = Command::new("auditctl")
7762                .args(["-l"])
7763                .output()
7764                .ok()
7765                .and_then(|o| String::from_utf8(o.stdout).ok())
7766                .unwrap_or_default();
7767            out.push_str("\n=== Active Audit Rules ===\n");
7768            if rules.trim().is_empty() || rules.contains("No rules") {
7769                out.push_str("  No rules configured.\n");
7770            } else {
7771                for line in rules.lines() {
7772                    out.push_str(&format!("  {}\n", line));
7773                }
7774            }
7775        }
7776    }
7777
7778    Ok(out.trim_end().to_string())
7779}
7780
7781// ── shares ────────────────────────────────────────────────────────────────────
7782
7783fn inspect_shares(max_entries: usize) -> Result<String, String> {
7784    let mut out = String::from("Host inspection: shares\n\n");
7785
7786    #[cfg(target_os = "windows")]
7787    {
7788        let smb_out = Command::new("powershell")
7789            .args([
7790                "-NoProfile", "-NonInteractive", "-Command",
7791                "Get-SmbShare | ForEach-Object { \"  $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
7792            ])
7793            .output()
7794            .ok()
7795            .and_then(|o| String::from_utf8(o.stdout).ok())
7796            .unwrap_or_default();
7797
7798        out.push_str("=== SMB Shares (exposed by this machine) ===\n");
7799        let smb_lines: Vec<&str> = smb_out
7800            .lines()
7801            .filter(|l| !l.trim().is_empty())
7802            .take(max_entries)
7803            .collect();
7804        if smb_lines.is_empty() {
7805            out.push_str("  No SMB shares or unable to retrieve.\n");
7806        } else {
7807            for line in &smb_lines {
7808                let name = line.trim().split('|').next().unwrap_or("").trim();
7809                if name.ends_with('$') {
7810                    out.push_str(&format!("  {}\n", line.trim()));
7811                } else {
7812                    out.push_str(&format!("  [CUSTOM] {}\n", line.trim()));
7813                }
7814            }
7815        }
7816
7817        let smb_security = Command::new("powershell")
7818            .args([
7819                "-NoProfile", "-NonInteractive", "-Command",
7820                "Get-SmbServerConfiguration | ForEach-Object { \"  SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
7821            ])
7822            .output()
7823            .ok()
7824            .and_then(|o| String::from_utf8(o.stdout).ok())
7825            .unwrap_or_default();
7826
7827        out.push_str("\n=== SMB Server Security Settings ===\n");
7828        if smb_security.trim().is_empty() {
7829            out.push_str("  (unable to retrieve)\n");
7830        } else {
7831            out.push_str(smb_security.trim());
7832            out.push('\n');
7833            if smb_security.to_lowercase().contains("smb1: true") {
7834                out.push_str("  [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
7835            }
7836        }
7837
7838        let drives_out = Command::new("powershell")
7839            .args([
7840                "-NoProfile", "-NonInteractive", "-Command",
7841                "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \"  $($_.Name): -> $($_.DisplayRoot)\" }",
7842            ])
7843            .output()
7844            .ok()
7845            .and_then(|o| String::from_utf8(o.stdout).ok())
7846            .unwrap_or_default();
7847
7848        out.push_str("\n=== Mapped Network Drives ===\n");
7849        if drives_out.trim().is_empty() {
7850            out.push_str("  None.\n");
7851        } else {
7852            for line in drives_out.lines().take(max_entries) {
7853                if !line.trim().is_empty() {
7854                    out.push_str(line);
7855                    out.push('\n');
7856                }
7857            }
7858        }
7859    }
7860
7861    #[cfg(not(target_os = "windows"))]
7862    {
7863        let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
7864        out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
7865        if smb_conf.is_empty() {
7866            out.push_str("  Not found or Samba not installed.\n");
7867        } else {
7868            for line in smb_conf.lines().take(max_entries) {
7869                out.push_str(&format!("  {}\n", line));
7870            }
7871        }
7872        let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
7873        out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
7874        if nfs_exports.is_empty() {
7875            out.push_str("  Not configured.\n");
7876        } else {
7877            for line in nfs_exports.lines().take(max_entries) {
7878                out.push_str(&format!("  {}\n", line));
7879            }
7880        }
7881    }
7882
7883    Ok(out.trim_end().to_string())
7884}
7885
7886// ── dns_servers ───────────────────────────────────────────────────────────────
7887
7888fn inspect_dns_servers() -> Result<String, String> {
7889    let mut out = String::from("Host inspection: dns_servers\n\n");
7890
7891    #[cfg(target_os = "windows")]
7892    {
7893        let dns_out = Command::new("powershell")
7894            .args([
7895                "-NoProfile", "-NonInteractive", "-Command",
7896                "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \"  $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
7897            ])
7898            .output()
7899            .ok()
7900            .and_then(|o| String::from_utf8(o.stdout).ok())
7901            .unwrap_or_default();
7902
7903        out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
7904        if dns_out.trim().is_empty() {
7905            out.push_str("  (unable to retrieve)\n");
7906        } else {
7907            for line in dns_out.lines() {
7908                if line.trim().is_empty() {
7909                    continue;
7910                }
7911                let mut annotation = "";
7912                if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
7913                    annotation = "  <- Google Public DNS";
7914                } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
7915                    annotation = "  <- Cloudflare DNS";
7916                } else if line.contains("9.9.9.9") {
7917                    annotation = "  <- Quad9";
7918                } else if line.contains("208.67.222") || line.contains("208.67.220") {
7919                    annotation = "  <- OpenDNS";
7920                }
7921                out.push_str(line);
7922                out.push_str(annotation);
7923                out.push('\n');
7924            }
7925        }
7926
7927        let doh_out = Command::new("powershell")
7928            .args([
7929                "-NoProfile", "-NonInteractive", "-Command",
7930                "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \"  $($_.ServerAddress): $($_.DohTemplate)\" }",
7931            ])
7932            .output()
7933            .ok()
7934            .and_then(|o| String::from_utf8(o.stdout).ok())
7935            .unwrap_or_default();
7936
7937        out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
7938        if doh_out.trim().is_empty() {
7939            out.push_str("  Not configured (plain DNS).\n");
7940        } else {
7941            out.push_str(doh_out.trim());
7942            out.push('\n');
7943        }
7944
7945        let suffixes = Command::new("powershell")
7946            .args([
7947                "-NoProfile", "-NonInteractive", "-Command",
7948                "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \"  $_\" }",
7949            ])
7950            .output()
7951            .ok()
7952            .and_then(|o| String::from_utf8(o.stdout).ok())
7953            .unwrap_or_default();
7954
7955        if !suffixes.trim().is_empty() {
7956            out.push_str("\n=== DNS Search Suffix List ===\n");
7957            out.push_str(suffixes.trim());
7958            out.push('\n');
7959        }
7960    }
7961
7962    #[cfg(not(target_os = "windows"))]
7963    {
7964        let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
7965        out.push_str("=== /etc/resolv.conf ===\n");
7966        if resolv.is_empty() {
7967            out.push_str("  Not found.\n");
7968        } else {
7969            for line in resolv.lines() {
7970                if !line.trim().is_empty() && !line.starts_with('#') {
7971                    out.push_str(&format!("  {}\n", line));
7972                }
7973            }
7974        }
7975        let resolved_out = Command::new("resolvectl")
7976            .args(["status", "--no-pager"])
7977            .output()
7978            .ok()
7979            .and_then(|o| String::from_utf8(o.stdout).ok())
7980            .unwrap_or_default();
7981        if !resolved_out.is_empty() {
7982            out.push_str("\n=== systemd-resolved ===\n");
7983            for line in resolved_out.lines().take(30) {
7984                out.push_str(&format!("  {}\n", line));
7985            }
7986        }
7987    }
7988
7989    Ok(out.trim_end().to_string())
7990}
7991
7992fn inspect_bitlocker() -> Result<String, String> {
7993    let mut out = String::from("Host inspection: bitlocker\n\n");
7994
7995    #[cfg(target_os = "windows")]
7996    {
7997        let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
7998        let output = Command::new("powershell")
7999            .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
8000            .output()
8001            .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
8002
8003        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
8004        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
8005
8006        if !stdout.trim().is_empty() {
8007            out.push_str("=== BitLocker Volumes ===\n");
8008            for line in stdout.lines() {
8009                out.push_str(&format!("  {}\n", line));
8010            }
8011        } else if !stderr.trim().is_empty() {
8012            if stderr.contains("Access is denied") {
8013                out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
8014            } else {
8015                out.push_str(&format!(
8016                    "Error retrieving BitLocker info: {}\n",
8017                    stderr.trim()
8018                ));
8019            }
8020        } else {
8021            out.push_str("No BitLocker volumes detected or access denied.\n");
8022        }
8023    }
8024
8025    #[cfg(not(target_os = "windows"))]
8026    {
8027        out.push_str(
8028            "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
8029        );
8030        let lsblk = Command::new("lsblk")
8031            .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
8032            .output()
8033            .ok()
8034            .and_then(|o| String::from_utf8(o.stdout).ok())
8035            .unwrap_or_default();
8036        if lsblk.contains("crypto_LUKS") {
8037            out.push_str("=== LUKS Encrypted Volumes ===\n");
8038            for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
8039                out.push_str(&format!("  {}\n", line));
8040            }
8041        } else {
8042            out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
8043        }
8044    }
8045
8046    Ok(out.trim_end().to_string())
8047}
8048
8049fn inspect_rdp() -> Result<String, String> {
8050    let mut out = String::from("Host inspection: rdp\n\n");
8051
8052    #[cfg(target_os = "windows")]
8053    {
8054        let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
8055        let f_deny = Command::new("powershell")
8056            .args([
8057                "-NoProfile",
8058                "-Command",
8059                &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
8060            ])
8061            .output()
8062            .ok()
8063            .and_then(|o| String::from_utf8(o.stdout).ok())
8064            .unwrap_or_default()
8065            .trim()
8066            .to_string();
8067
8068        let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
8069        out.push_str(&format!("=== RDP Status: {} ===\n", status));
8070
8071        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"])
8072            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
8073        out.push_str(&format!(
8074            "  Port: {}\n",
8075            if port.is_empty() {
8076                "3389 (default)"
8077            } else {
8078                &port
8079            }
8080        ));
8081
8082        let nla = Command::new("powershell")
8083            .args([
8084                "-NoProfile",
8085                "-Command",
8086                &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
8087            ])
8088            .output()
8089            .ok()
8090            .and_then(|o| String::from_utf8(o.stdout).ok())
8091            .unwrap_or_default()
8092            .trim()
8093            .to_string();
8094        out.push_str(&format!(
8095            "  NLA Required: {}\n",
8096            if nla == "1" { "Yes" } else { "No" }
8097        ));
8098
8099        out.push_str("\n=== Active Sessions ===\n");
8100        let qwinsta = Command::new("qwinsta")
8101            .output()
8102            .ok()
8103            .and_then(|o| String::from_utf8(o.stdout).ok())
8104            .unwrap_or_default();
8105        if qwinsta.trim().is_empty() {
8106            out.push_str("  No active sessions listed.\n");
8107        } else {
8108            for line in qwinsta.lines() {
8109                out.push_str(&format!("  {}\n", line));
8110            }
8111        }
8112
8113        out.push_str("\n=== Firewall Rule Check ===\n");
8114        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))\" }"])
8115            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8116        if fw.trim().is_empty() {
8117            out.push_str("  No enabled RDP firewall rules found.\n");
8118        } else {
8119            out.push_str(fw.trim_end());
8120            out.push('\n');
8121        }
8122    }
8123
8124    #[cfg(not(target_os = "windows"))]
8125    {
8126        out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
8127        let ss = Command::new("ss")
8128            .args(["-tlnp"])
8129            .output()
8130            .ok()
8131            .and_then(|o| String::from_utf8(o.stdout).ok())
8132            .unwrap_or_default();
8133        let matches: Vec<&str> = ss
8134            .lines()
8135            .filter(|l| l.contains(":3389") || l.contains(":590"))
8136            .collect();
8137        if matches.is_empty() {
8138            out.push_str("  No RDP/VNC listeners detected via 'ss'.\n");
8139        } else {
8140            for m in matches {
8141                out.push_str(&format!("  {}\n", m));
8142            }
8143        }
8144    }
8145
8146    Ok(out.trim_end().to_string())
8147}
8148
8149fn inspect_shadow_copies() -> Result<String, String> {
8150    let mut out = String::from("Host inspection: shadow_copies\n\n");
8151
8152    #[cfg(target_os = "windows")]
8153    {
8154        let output = Command::new("vssadmin")
8155            .args(["list", "shadows"])
8156            .output()
8157            .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
8158        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
8159
8160        if stdout.contains("No items found") || stdout.trim().is_empty() {
8161            out.push_str("No Volume Shadow Copies found.\n");
8162        } else {
8163            out.push_str("=== Volume Shadow Copies ===\n");
8164            for line in stdout.lines().take(50) {
8165                if line.contains("Creation Time:")
8166                    || line.contains("Contents:")
8167                    || line.contains("Volume Name:")
8168                {
8169                    out.push_str(&format!("  {}\n", line.trim()));
8170                }
8171            }
8172        }
8173
8174        out.push_str("\n=== Shadow Copy Storage ===\n");
8175        let storage_out = Command::new("vssadmin")
8176            .args(["list", "shadowstorage"])
8177            .output()
8178            .ok();
8179        if let Some(o) = storage_out {
8180            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8181            for line in stdout.lines() {
8182                if line.contains("Used Shadow Copy Storage space:")
8183                    || line.contains("Max Shadow Copy Storage space:")
8184                {
8185                    out.push_str(&format!("  {}\n", line.trim()));
8186                }
8187            }
8188        }
8189    }
8190
8191    #[cfg(not(target_os = "windows"))]
8192    {
8193        out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
8194        let lvs = Command::new("lvs")
8195            .output()
8196            .ok()
8197            .and_then(|o| String::from_utf8(o.stdout).ok())
8198            .unwrap_or_default();
8199        if !lvs.is_empty() {
8200            out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
8201            out.push_str(&lvs);
8202        } else {
8203            out.push_str("No LVM volumes detected.\n");
8204        }
8205    }
8206
8207    Ok(out.trim_end().to_string())
8208}
8209
8210fn inspect_pagefile() -> Result<String, String> {
8211    let mut out = String::from("Host inspection: pagefile\n\n");
8212
8213    #[cfg(target_os = "windows")]
8214    {
8215        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)\" }";
8216        let output = Command::new("powershell")
8217            .args(["-NoProfile", "-Command", ps_cmd])
8218            .output()
8219            .ok()
8220            .and_then(|o| String::from_utf8(o.stdout).ok())
8221            .unwrap_or_default();
8222
8223        if output.trim().is_empty() {
8224            out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
8225            let managed = Command::new("powershell")
8226                .args([
8227                    "-NoProfile",
8228                    "-Command",
8229                    "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
8230                ])
8231                .output()
8232                .ok()
8233                .and_then(|o| String::from_utf8(o.stdout).ok())
8234                .unwrap_or_default()
8235                .trim()
8236                .to_string();
8237            out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
8238        } else {
8239            out.push_str("=== Page File Usage ===\n");
8240            out.push_str(&output);
8241        }
8242    }
8243
8244    #[cfg(not(target_os = "windows"))]
8245    {
8246        out.push_str("=== Swap Usage (Linux/macOS) ===\n");
8247        let swap = Command::new("swapon")
8248            .args(["--show"])
8249            .output()
8250            .ok()
8251            .and_then(|o| String::from_utf8(o.stdout).ok())
8252            .unwrap_or_default();
8253        if swap.is_empty() {
8254            let free = Command::new("free")
8255                .args(["-h"])
8256                .output()
8257                .ok()
8258                .and_then(|o| String::from_utf8(o.stdout).ok())
8259                .unwrap_or_default();
8260            out.push_str(&free);
8261        } else {
8262            out.push_str(&swap);
8263        }
8264    }
8265
8266    Ok(out.trim_end().to_string())
8267}
8268
8269fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
8270    let mut out = String::from("Host inspection: windows_features\n\n");
8271
8272    #[cfg(target_os = "windows")]
8273    {
8274        out.push_str("=== Quick Check: Notable Features ===\n");
8275        let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
8276        let output = Command::new("powershell")
8277            .args(["-NoProfile", "-Command", quick_ps])
8278            .output()
8279            .ok();
8280
8281        if let Some(o) = output {
8282            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8283            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8284
8285            if !stdout.trim().is_empty() {
8286                for f in stdout.lines() {
8287                    out.push_str(&format!("  [ENABLED] {}\n", f));
8288                }
8289            } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
8290                out.push_str("  Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
8291            } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
8292                out.push_str(
8293                    "  No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
8294                );
8295            }
8296        }
8297
8298        out.push_str(&format!(
8299            "\n=== All Enabled Features (capped at {}) ===\n",
8300            max_entries
8301        ));
8302        let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
8303        let all_out = Command::new("powershell")
8304            .args(["-NoProfile", "-Command", &all_ps])
8305            .output()
8306            .ok();
8307        if let Some(o) = all_out {
8308            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8309            if !stdout.trim().is_empty() {
8310                out.push_str(&stdout);
8311            }
8312        }
8313    }
8314
8315    #[cfg(not(target_os = "windows"))]
8316    {
8317        let _ = max_entries;
8318        out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
8319    }
8320
8321    Ok(out.trim_end().to_string())
8322}
8323
8324fn inspect_printers(max_entries: usize) -> Result<String, String> {
8325    let mut out = String::from("Host inspection: printers\n\n");
8326
8327    #[cfg(target_os = "windows")]
8328    {
8329        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)])
8330            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8331        if list.trim().is_empty() {
8332            out.push_str("No printers detected.\n");
8333        } else {
8334            out.push_str("=== Installed Printers ===\n");
8335            out.push_str(&list);
8336        }
8337
8338        let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \"  [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
8339            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8340        if !jobs.trim().is_empty() {
8341            out.push_str("\n=== Active Print Jobs ===\n");
8342            out.push_str(&jobs);
8343        }
8344    }
8345
8346    #[cfg(not(target_os = "windows"))]
8347    {
8348        let _ = max_entries;
8349        out.push_str("Checking LPSTAT for printers...\n");
8350        let lpstat = Command::new("lpstat")
8351            .args(["-p", "-d"])
8352            .output()
8353            .ok()
8354            .and_then(|o| String::from_utf8(o.stdout).ok())
8355            .unwrap_or_default();
8356        if lpstat.is_empty() {
8357            out.push_str("  No CUPS/LP printers found.\n");
8358        } else {
8359            out.push_str(&lpstat);
8360        }
8361    }
8362
8363    Ok(out.trim_end().to_string())
8364}
8365
8366fn inspect_winrm() -> Result<String, String> {
8367    let mut out = String::from("Host inspection: winrm\n\n");
8368
8369    #[cfg(target_os = "windows")]
8370    {
8371        let svc = Command::new("powershell")
8372            .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
8373            .output()
8374            .ok()
8375            .and_then(|o| String::from_utf8(o.stdout).ok())
8376            .unwrap_or_default()
8377            .trim()
8378            .to_string();
8379        out.push_str(&format!(
8380            "WinRM Service Status: {}\n\n",
8381            if svc.is_empty() { "NOT_FOUND" } else { &svc }
8382        ));
8383
8384        out.push_str("=== WinRM Listeners ===\n");
8385        let output = Command::new("powershell")
8386            .args([
8387                "-NoProfile",
8388                "-Command",
8389                "winrm enumerate winrm/config/listener 2>$null",
8390            ])
8391            .output()
8392            .ok();
8393        if let Some(o) = output {
8394            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8395            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8396
8397            if !stdout.trim().is_empty() {
8398                for line in stdout.lines() {
8399                    if line.contains("Address =")
8400                        || line.contains("Transport =")
8401                        || line.contains("Port =")
8402                    {
8403                        out.push_str(&format!("  {}\n", line.trim()));
8404                    }
8405                }
8406            } else if stderr.contains("Access is denied") {
8407                out.push_str("  Error: Access denied to WinRM configuration.\n");
8408            } else {
8409                out.push_str("  No listeners configured.\n");
8410            }
8411        }
8412
8413        out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
8414        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))\" }"])
8415            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8416        if test_out.trim().is_empty() {
8417            out.push_str("  WinRM not responding to local WS-Man requests.\n");
8418        } else {
8419            out.push_str(&test_out);
8420        }
8421    }
8422
8423    #[cfg(not(target_os = "windows"))]
8424    {
8425        out.push_str(
8426            "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
8427        );
8428        let ss = Command::new("ss")
8429            .args(["-tln"])
8430            .output()
8431            .ok()
8432            .and_then(|o| String::from_utf8(o.stdout).ok())
8433            .unwrap_or_default();
8434        if ss.contains(":5985") || ss.contains(":5986") {
8435            out.push_str("  WinRM ports (5985/5986) are listening.\n");
8436        } else {
8437            out.push_str("  WinRM ports not detected.\n");
8438        }
8439    }
8440
8441    Ok(out.trim_end().to_string())
8442}
8443
8444fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
8445    let mut out = String::from("Host inspection: network_stats\n\n");
8446
8447    #[cfg(target_os = "windows")]
8448    {
8449        let ps_cmd = format!("Get-NetAdapterStatistics | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {} | ForEach-Object {{ \"  $($_.Name): RX:$([math]::round($($_.ReceivedBytes)/1MB, 2))MB, TX:$([math]::round($($_.SentBytes)/1MB, 2))MB, Errors(RX/TX): $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" }}", max_entries);
8450        let output = Command::new("powershell")
8451            .args(["-NoProfile", "-Command", &ps_cmd])
8452            .output()
8453            .ok()
8454            .and_then(|o| String::from_utf8(o.stdout).ok())
8455            .unwrap_or_default();
8456        if output.trim().is_empty() {
8457            out.push_str("No network adapter statistics available.\n");
8458        } else {
8459            out.push_str("=== Adapter Throughput & Errors ===\n");
8460            out.push_str(&output);
8461        }
8462
8463        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)\" } }"])
8464            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8465        if !discards.trim().is_empty() {
8466            out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
8467            out.push_str(&discards);
8468        }
8469    }
8470
8471    #[cfg(not(target_os = "windows"))]
8472    {
8473        let _ = max_entries;
8474        out.push_str("=== Network Stats (ip -s link) ===\n");
8475        let ip_s = Command::new("ip")
8476            .args(["-s", "link"])
8477            .output()
8478            .ok()
8479            .and_then(|o| String::from_utf8(o.stdout).ok())
8480            .unwrap_or_default();
8481        if ip_s.is_empty() {
8482            let netstat = Command::new("netstat")
8483                .args(["-i"])
8484                .output()
8485                .ok()
8486                .and_then(|o| String::from_utf8(o.stdout).ok())
8487                .unwrap_or_default();
8488            out.push_str(&netstat);
8489        } else {
8490            out.push_str(&ip_s);
8491        }
8492    }
8493
8494    Ok(out.trim_end().to_string())
8495}
8496
8497fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
8498    let mut out = String::from("Host inspection: udp_ports\n\n");
8499
8500    #[cfg(target_os = "windows")]
8501    {
8502        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);
8503        let output = Command::new("powershell")
8504            .args(["-NoProfile", "-Command", &ps_cmd])
8505            .output()
8506            .ok();
8507
8508        if let Some(o) = output {
8509            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8510            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8511
8512            if !stdout.trim().is_empty() {
8513                out.push_str("=== UDP Listeners (Local:Port) ===\n");
8514                for line in stdout.lines() {
8515                    let mut note = "";
8516                    if line.contains(":53 ") {
8517                        note = " [DNS]";
8518                    } else if line.contains(":67 ") || line.contains(":68 ") {
8519                        note = " [DHCP]";
8520                    } else if line.contains(":123 ") {
8521                        note = " [NTP]";
8522                    } else if line.contains(":161 ") {
8523                        note = " [SNMP]";
8524                    } else if line.contains(":1900 ") {
8525                        note = " [SSDP/UPnP]";
8526                    } else if line.contains(":5353 ") {
8527                        note = " [mDNS]";
8528                    }
8529
8530                    out.push_str(&format!("{}{}\n", line, note));
8531                }
8532            } else if stderr.contains("Access is denied") {
8533                out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
8534            } else {
8535                out.push_str("No UDP listeners detected.\n");
8536            }
8537        }
8538    }
8539
8540    #[cfg(not(target_os = "windows"))]
8541    {
8542        let ss_out = Command::new("ss")
8543            .args(["-ulnp"])
8544            .output()
8545            .ok()
8546            .and_then(|o| String::from_utf8(o.stdout).ok())
8547            .unwrap_or_default();
8548        out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
8549        if ss_out.is_empty() {
8550            let netstat_out = Command::new("netstat")
8551                .args(["-ulnp"])
8552                .output()
8553                .ok()
8554                .and_then(|o| String::from_utf8(o.stdout).ok())
8555                .unwrap_or_default();
8556            if netstat_out.is_empty() {
8557                out.push_str("  Neither 'ss' nor 'netstat' available.\n");
8558            } else {
8559                for line in netstat_out.lines().take(max_entries) {
8560                    out.push_str(&format!("  {}\n", line));
8561                }
8562            }
8563        } else {
8564            for line in ss_out.lines().take(max_entries) {
8565                out.push_str(&format!("  {}\n", line));
8566            }
8567        }
8568    }
8569
8570    Ok(out.trim_end().to_string())
8571}
8572
8573fn inspect_gpo() -> Result<String, String> {
8574    let mut out = String::from("Host inspection: gpo\n\n");
8575
8576    #[cfg(target_os = "windows")]
8577    {
8578        let output = Command::new("gpresult")
8579            .args(["/r", "/scope", "computer"])
8580            .output()
8581            .ok();
8582
8583        if let Some(o) = output {
8584            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8585            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8586
8587            if stdout.contains("Applied Group Policy Objects") {
8588                out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
8589                let mut capture = false;
8590                for line in stdout.lines() {
8591                    if line.contains("Applied Group Policy Objects") {
8592                        capture = true;
8593                    } else if capture && line.contains("The following GPOs were not applied") {
8594                        break;
8595                    }
8596                    if capture && !line.trim().is_empty() {
8597                        out.push_str(&format!("  {}\n", line.trim()));
8598                    }
8599                }
8600            } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
8601                out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
8602            } else {
8603                out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
8604            }
8605        }
8606    }
8607
8608    #[cfg(not(target_os = "windows"))]
8609    {
8610        out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
8611    }
8612
8613    Ok(out.trim_end().to_string())
8614}
8615
8616fn inspect_certificates(max_entries: usize) -> Result<String, String> {
8617    let mut out = String::from("Host inspection: certificates\n\n");
8618
8619    #[cfg(target_os = "windows")]
8620    {
8621        let ps_cmd = format!(
8622            "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
8623                $days = ($_.NotAfter - (Get-Date)).Days; \
8624                $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
8625                \"  $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
8626            }}", 
8627            max_entries
8628        );
8629        let output = Command::new("powershell")
8630            .args(["-NoProfile", "-Command", &ps_cmd])
8631            .output()
8632            .ok();
8633
8634        if let Some(o) = output {
8635            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8636            if !stdout.trim().is_empty() {
8637                out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
8638                out.push_str(&stdout);
8639            } else {
8640                out.push_str("No certificates found in the Local Machine Personal store.\n");
8641            }
8642        }
8643    }
8644
8645    #[cfg(not(target_os = "windows"))]
8646    {
8647        let _ = max_entries;
8648        out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
8649        // Check standard cert locations
8650        for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
8651            if Path::new(path).exists() {
8652                out.push_str(&format!("  Cert directory found: {}\n", path));
8653            }
8654        }
8655    }
8656
8657    Ok(out.trim_end().to_string())
8658}
8659
8660fn inspect_integrity() -> Result<String, String> {
8661    let mut out = String::from("Host inspection: integrity\n\n");
8662
8663    #[cfg(target_os = "windows")]
8664    {
8665        let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
8666        let output = Command::new("powershell")
8667            .args(["-NoProfile", "-Command", &ps_cmd])
8668            .output()
8669            .ok();
8670
8671        if let Some(o) = output {
8672            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8673            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
8674                out.push_str("=== Windows Component Store Health (CBS) ===\n");
8675                let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
8676                let repair = val
8677                    .get("AutoRepairNeeded")
8678                    .and_then(|v| v.as_u64())
8679                    .unwrap_or(0);
8680
8681                out.push_str(&format!(
8682                    "  Corruption Detected: {}\n",
8683                    if corrupt != 0 {
8684                        "YES (SFC/DISM recommended)"
8685                    } else {
8686                        "No"
8687                    }
8688                ));
8689                out.push_str(&format!(
8690                    "  Auto-Repair Needed: {}\n",
8691                    if repair != 0 { "YES" } else { "No" }
8692                ));
8693
8694                if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
8695                    out.push_str(&format!("  Last Repair Attempt: (Raw code: {})\n", last));
8696                }
8697            } else {
8698                out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
8699            }
8700        }
8701
8702        if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
8703            out.push_str(
8704                "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
8705            );
8706        }
8707    }
8708
8709    #[cfg(not(target_os = "windows"))]
8710    {
8711        out.push_str("System integrity check (Linux)\n\n");
8712        let pkg_check = Command::new("rpm")
8713            .args(["-Va"])
8714            .output()
8715            .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
8716            .ok();
8717        if let Some(o) = pkg_check {
8718            out.push_str("  Package verification system active.\n");
8719            if o.status.success() {
8720                out.push_str("  No major package integrity issues detected.\n");
8721            }
8722        }
8723    }
8724
8725    Ok(out.trim_end().to_string())
8726}
8727
8728fn inspect_domain() -> Result<String, String> {
8729    let mut out = String::from("Host inspection: domain\n\n");
8730
8731    #[cfg(target_os = "windows")]
8732    {
8733        let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
8734        let output = Command::new("powershell")
8735            .args(["-NoProfile", "-Command", &ps_cmd])
8736            .output()
8737            .ok();
8738
8739        if let Some(o) = output {
8740            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8741            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
8742                let part_of_domain = val
8743                    .get("PartOfDomain")
8744                    .and_then(|v| v.as_bool())
8745                    .unwrap_or(false);
8746                let domain = val
8747                    .get("Domain")
8748                    .and_then(|v| v.as_str())
8749                    .unwrap_or("Unknown");
8750                let workgroup = val
8751                    .get("Workgroup")
8752                    .and_then(|v| v.as_str())
8753                    .unwrap_or("Unknown");
8754
8755                out.push_str("=== Windows Domain / Workgroup Identity ===\n");
8756                out.push_str(&format!(
8757                    "  Join Status: {}\n",
8758                    if part_of_domain {
8759                        "DOMAIN JOINED"
8760                    } else {
8761                        "WORKGROUP"
8762                    }
8763                ));
8764                if part_of_domain {
8765                    out.push_str(&format!("  Active Directory Domain: {}\n", domain));
8766                } else {
8767                    out.push_str(&format!("  Workgroup Name: {}\n", workgroup));
8768                }
8769
8770                if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
8771                    out.push_str(&format!("  NetBIOS Name: {}\n", name));
8772                }
8773            }
8774        }
8775    }
8776
8777    #[cfg(not(target_os = "windows"))]
8778    {
8779        let domainname = Command::new("domainname")
8780            .output()
8781            .ok()
8782            .and_then(|o| String::from_utf8(o.stdout).ok())
8783            .unwrap_or_default();
8784        out.push_str("=== Linux Domain Identity ===\n");
8785        if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
8786            out.push_str(&format!("  NIS/YP Domain: {}\n", domainname.trim()));
8787        } else {
8788            out.push_str("  No NIS domain configured.\n");
8789        }
8790    }
8791
8792    Ok(out.trim_end().to_string())
8793}
8794
8795fn inspect_device_health() -> Result<String, String> {
8796    let mut out = String::from("Host inspection: device_health\n\n");
8797
8798    #[cfg(target_os = "windows")]
8799    {
8800        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)\" }";
8801        let output = Command::new("powershell")
8802            .args(["-NoProfile", "-Command", ps_cmd])
8803            .output()
8804            .ok()
8805            .and_then(|o| String::from_utf8(o.stdout).ok())
8806            .unwrap_or_default();
8807
8808        if output.trim().is_empty() {
8809            out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
8810        } else {
8811            out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
8812            out.push_str(&output);
8813            out.push_str(
8814                "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
8815            );
8816        }
8817    }
8818
8819    #[cfg(not(target_os = "windows"))]
8820    {
8821        out.push_str("Checking dmesg for hardware errors...\n");
8822        let dmesg = Command::new("dmesg")
8823            .args(["--level=err,crit,alert"])
8824            .output()
8825            .ok()
8826            .and_then(|o| String::from_utf8(o.stdout).ok())
8827            .unwrap_or_default();
8828        if dmesg.is_empty() {
8829            out.push_str("  No critical hardware errors found in dmesg.\n");
8830        } else {
8831            out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
8832        }
8833    }
8834
8835    Ok(out.trim_end().to_string())
8836}
8837
8838fn inspect_drivers(max_entries: usize) -> Result<String, String> {
8839    let mut out = String::from("Host inspection: drivers\n\n");
8840
8841    #[cfg(target_os = "windows")]
8842    {
8843        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);
8844        let output = Command::new("powershell")
8845            .args(["-NoProfile", "-Command", &ps_cmd])
8846            .output()
8847            .ok()
8848            .and_then(|o| String::from_utf8(o.stdout).ok())
8849            .unwrap_or_default();
8850
8851        if output.trim().is_empty() {
8852            out.push_str("No drivers retrieved via WMI.\n");
8853        } else {
8854            out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
8855            out.push_str(&output);
8856        }
8857    }
8858
8859    #[cfg(not(target_os = "windows"))]
8860    {
8861        out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
8862        let lsmod = Command::new("lsmod")
8863            .output()
8864            .ok()
8865            .and_then(|o| String::from_utf8(o.stdout).ok())
8866            .unwrap_or_default();
8867        out.push_str(
8868            &lsmod
8869                .lines()
8870                .take(max_entries)
8871                .collect::<Vec<_>>()
8872                .join("\n"),
8873        );
8874    }
8875
8876    Ok(out.trim_end().to_string())
8877}
8878
8879fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
8880    let mut out = String::from("Host inspection: peripherals\n\n");
8881
8882    #[cfg(target_os = "windows")]
8883    {
8884        let _ = max_entries;
8885        out.push_str("=== USB Controllers & Hubs ===\n");
8886        let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \"  $($_.Name) ($($_.Status))\" }"])
8887            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8888        out.push_str(if usb.is_empty() {
8889            "  None detected.\n"
8890        } else {
8891            &usb
8892        });
8893
8894        out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
8895        let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \"  [KB] $($_.Name) ($($_.Status))\" }"])
8896            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8897        let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \"  [PTR] $($_.Name) ($($_.Status))\" }"])
8898            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8899        out.push_str(&kb);
8900        out.push_str(&mouse);
8901
8902        out.push_str("\n=== Connected Monitors (WMI) ===\n");
8903        let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \"  Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
8904            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8905        out.push_str(if mon.is_empty() {
8906            "  No active monitors identified via WMI.\n"
8907        } else {
8908            &mon
8909        });
8910    }
8911
8912    #[cfg(not(target_os = "windows"))]
8913    {
8914        out.push_str("=== Connected USB Devices (lsusb) ===\n");
8915        let lsusb = Command::new("lsusb")
8916            .output()
8917            .ok()
8918            .and_then(|o| String::from_utf8(o.stdout).ok())
8919            .unwrap_or_default();
8920        out.push_str(
8921            &lsusb
8922                .lines()
8923                .take(max_entries)
8924                .collect::<Vec<_>>()
8925                .join("\n"),
8926        );
8927    }
8928
8929    Ok(out.trim_end().to_string())
8930}
8931
8932fn inspect_sessions(max_entries: usize) -> Result<String, String> {
8933    let mut out = String::from("Host inspection: sessions\n\n");
8934
8935    #[cfg(target_os = "windows")]
8936    {
8937        let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
8938    "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
8939}"#;
8940        if let Ok(o) = Command::new("powershell")
8941            .args(["-NoProfile", "-Command", script])
8942            .output()
8943        {
8944            let text = String::from_utf8_lossy(&o.stdout);
8945            let lines: Vec<&str> = text.lines().collect();
8946            if lines.is_empty() {
8947                out.push_str("No active logon sessions enumerated via WMI.\n");
8948            } else {
8949                out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
8950                for line in lines
8951                    .iter()
8952                    .take(max_entries)
8953                    .filter(|l| !l.trim().is_empty())
8954                {
8955                    let parts: Vec<&str> = line.trim().split('|').collect();
8956                    if parts.len() == 4 {
8957                        let logon_type = match parts[2] {
8958                            "2" => "Interactive",
8959                            "3" => "Network",
8960                            "4" => "Batch",
8961                            "5" => "Service",
8962                            "7" => "Unlock",
8963                            "8" => "NetworkCleartext",
8964                            "9" => "NewCredentials",
8965                            "10" => "RemoteInteractive",
8966                            "11" => "CachedInteractive",
8967                            _ => "Other",
8968                        };
8969                        out.push_str(&format!(
8970                            "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
8971                            parts[0], logon_type, parts[1], parts[3]
8972                        ));
8973                    }
8974                }
8975            }
8976        }
8977    }
8978
8979    #[cfg(not(target_os = "windows"))]
8980    {
8981        out.push_str("=== Logged-in Users (who) ===\n");
8982        let who = Command::new("who")
8983            .output()
8984            .ok()
8985            .and_then(|o| String::from_utf8(o.stdout).ok())
8986            .unwrap_or_default();
8987        out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
8988    }
8989
8990    Ok(out.trim_end().to_string())
8991}
8992
8993async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
8994    let mut out = String::from("Host inspection: disk_benchmark\n\n");
8995    let mut final_path = path;
8996
8997    if !final_path.exists() {
8998        if let Ok(current_exe) = std::env::current_exe() {
8999            out.push_str(&format!(
9000                "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
9001                final_path.display()
9002            ));
9003            final_path = current_exe;
9004        } else {
9005            return Err(format!("Target not found: {}", final_path.display()));
9006        }
9007    }
9008
9009    let target = if final_path.is_dir() {
9010        // Find a representative file to read
9011        let mut target_file = final_path.join("Cargo.toml");
9012        if !target_file.exists() {
9013            target_file = final_path.join("README.md");
9014        }
9015        if !target_file.exists() {
9016            return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
9017        }
9018        target_file
9019    } else {
9020        final_path
9021    };
9022
9023    out.push_str(&format!("Target: {}\n", target.display()));
9024    out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
9025
9026    #[cfg(target_os = "windows")]
9027    {
9028        let script = format!(
9029            r#"
9030$target = "{}"
9031if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
9032
9033$diskQueue = @()
9034$readStats = @()
9035$startTime = Get-Date
9036$duration = 5
9037
9038# Background reader job
9039$job = Start-Job -ScriptBlock {{
9040    param($t, $d)
9041    $stop = (Get-Date).AddSeconds($d)
9042    while ((Get-Date) -lt $stop) {{
9043        try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
9044    }}
9045}} -ArgumentList $target, $duration
9046
9047# Metrics collector loop
9048$stopTime = (Get-Date).AddSeconds($duration)
9049while ((Get-Date) -lt $stopTime) {{
9050    $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
9051    if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
9052    
9053    $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
9054    if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
9055    
9056    Start-Sleep -Milliseconds 250
9057}}
9058
9059Stop-Job $job
9060Receive-Job $job | Out-Null
9061Remove-Job $job
9062
9063$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
9064$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
9065$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
9066
9067"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
9068"#,
9069            target.display()
9070        );
9071
9072        let output = Command::new("powershell")
9073            .args(["-NoProfile", "-Command", &script])
9074            .output()
9075            .map_err(|e| format!("Benchmark failed: {e}"))?;
9076
9077        let raw = String::from_utf8_lossy(&output.stdout);
9078        let text = raw.trim();
9079
9080        if text.starts_with("ERROR") {
9081            return Err(text.to_string());
9082        }
9083
9084        let mut lines = text.lines();
9085        if let Some(metrics_line) = lines.next() {
9086            let parts: Vec<&str> = metrics_line.split('|').collect();
9087            let mut avg_q = "unknown".to_string();
9088            let mut max_q = "unknown".to_string();
9089            let mut avg_r = "unknown".to_string();
9090
9091            for p in parts {
9092                if let Some((k, v)) = p.split_once(':') {
9093                    match k {
9094                        "AVG_Q" => avg_q = v.to_string(),
9095                        "MAX_Q" => max_q = v.to_string(),
9096                        "AVG_R" => avg_r = v.to_string(),
9097                        _ => {}
9098                    }
9099                }
9100            }
9101
9102            out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
9103            out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
9104            out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
9105            out.push_str(&format!("- Disk Throughput (Avg):  {} reads/sec\n", avg_r));
9106            out.push_str("\nVerdict: ");
9107            let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
9108            if q_num > 1.0 {
9109                out.push_str(
9110                    "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
9111                );
9112            } else if q_num > 0.1 {
9113                out.push_str("MODERATE LOAD — significant I/O pressure detected.");
9114            } else {
9115                out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
9116            }
9117        }
9118    }
9119
9120    #[cfg(not(target_os = "windows"))]
9121    {
9122        out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
9123        out.push_str("Generic disk load simulated.\n");
9124    }
9125
9126    Ok(out)
9127}