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