Skip to main content

hematite/tools/
host_inspect.rs

1use serde_json::Value;
2use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7const DEFAULT_MAX_ENTRIES: usize = 10;
8const MAX_ENTRIES_CAP: usize = 25;
9const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
10
11pub async fn inspect_host(args: &Value) -> Result<String, String> {
12    let mut topic = args
13        .get("topic")
14        .and_then(|v| v.as_str())
15        .unwrap_or("summary")
16        .to_string();
17    let max_entries = parse_max_entries(args);
18    let filter = parse_name_filter(args).unwrap_or_default().to_lowercase();
19
20    // Topic Interceptor: Force ad_user for AD-related queries to resolve model variance
21    if (topic == "processes" || topic == "network" || topic == "summary") && 
22       (filter.contains("ad") || filter.contains("sid") || filter.contains("administrator") || filter.contains("domain")) {
23        topic = "ad_user".to_string();
24    }
25
26    match topic.as_str() {
27        "summary" => inspect_summary(max_entries),
28        "toolchains" => inspect_toolchains(),
29        "path" => inspect_path(max_entries),
30        "env_doctor" => inspect_env_doctor(max_entries),
31        "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
32        "network" => inspect_network(max_entries),
33        "services" => inspect_services(parse_name_filter(args), max_entries),
34        "processes" => inspect_processes(parse_name_filter(args), max_entries),
35        "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
36        "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
37        "disk" => {
38            let path = resolve_optional_path(args)?;
39            inspect_disk(path, max_entries).await
40        }
41        "ports" => inspect_ports(parse_port_filter(args), max_entries),
42        "log_check" => inspect_log_check(parse_lookback_hours(args), max_entries),
43        "startup_items" | "startup" | "boot" | "autorun" => inspect_startup_items(max_entries),
44        "health_report" | "system_health" => inspect_health_report(),
45        "storage" => inspect_storage(max_entries),
46        "hardware" => inspect_hardware(),
47        "updates" | "windows_update" => inspect_updates(),
48        "security" | "antivirus" | "defender" => inspect_security(),
49        "pending_reboot" | "reboot_required" => inspect_pending_reboot(),
50        "disk_health" | "smart" | "drive_health" => inspect_disk_health(),
51        "battery" => inspect_battery(),
52        "recent_crashes" | "crashes" | "bsod" => inspect_recent_crashes(max_entries),
53        "scheduled_tasks" | "tasks" => inspect_scheduled_tasks(max_entries),
54        "dev_conflicts" | "dev_environment" => inspect_dev_conflicts(),
55        "connectivity" | "internet" | "internet_check" => inspect_connectivity(),
56        "wifi" | "wi-fi" | "wireless" | "wlan" => inspect_wifi(),
57        "connections" | "tcp_connections" | "active_connections" => inspect_connections(max_entries),
58        "vpn" => inspect_vpn(),
59        "proxy" | "proxy_settings" => inspect_proxy(),
60        "firewall_rules" | "firewall-rules" => inspect_firewall_rules(max_entries),
61        "traceroute" | "tracert" | "trace_route" | "trace" => {
62            let host = args
63                .get("host")
64                .and_then(|v| v.as_str())
65                .unwrap_or("8.8.8.8")
66                .to_string();
67            inspect_traceroute(&host, max_entries)
68        }
69        "dns_cache" | "dnscache" | "dns-cache" => inspect_dns_cache(max_entries),
70        "arp" | "arp_table" => inspect_arp(),
71        "route_table" | "routes" | "routing_table" => inspect_route_table(max_entries),
72        "os_config" | "system_config" => inspect_os_config(),
73        "resource_load" | "performance" | "system_load" | "performance_diagnosis" => inspect_resource_load(),
74        "env" | "environment" | "environment_variables" | "env_vars" => inspect_env(max_entries),
75        "hosts_file" | "hosts" | "etc_hosts" => inspect_hosts_file(),
76        "docker" | "containers" | "docker_status" => inspect_docker(max_entries),
77        "wsl" | "wsl_distros" | "subsystem" => inspect_wsl(),
78        "ssh" | "ssh_config" | "ssh_status" => inspect_ssh(),
79        "installed_software" | "installed" | "programs" | "software" | "packages" => inspect_installed_software(max_entries),
80        "git_config" | "git_global" => inspect_git_config(),
81        "databases" | "database" | "db_services" | "db" => inspect_databases(),
82        "user_accounts" | "users" | "local_users" | "accounts" => inspect_user_accounts(max_entries),
83        "audit_policy" | "audit" | "auditpol" => inspect_audit_policy(),
84        "shares" | "smb_shares" | "network_shares" | "mapped_drives" => inspect_shares(max_entries),
85        "dns_servers" | "dns_config" | "dns_resolver" | "nameservers" => inspect_dns_servers(),
86        "bitlocker" | "encryption" | "drive_encryption" | "bitlocker_status" => inspect_bitlocker(),
87        "rdp" | "remote_desktop" | "rdp_status" => inspect_rdp(),
88        "shadow_copies" | "vss" | "volume_shadow" | "backups" | "snapshots" => inspect_shadow_copies(),
89        "pagefile" | "page_file" | "virtual_memory" | "swap" => inspect_pagefile(),
90        "windows_features" | "optional_features" | "installed_features" | "features" => inspect_windows_features(max_entries),
91        "printers" | "printer" | "print_queue" | "printing" => inspect_printers(max_entries),
92        "winrm" | "remote_management" | "psremoting" => inspect_winrm(),
93        "network_stats" | "adapter_stats" | "nic_stats" | "interface_stats" => inspect_network_stats(max_entries),
94        "udp_ports" | "udp_listeners" | "udp" => inspect_udp_ports(max_entries),
95        "gpo" | "group_policy" | "applied_policies" => inspect_gpo(),
96        "certificates" | "certs" | "ssl_certs" => inspect_certificates(max_entries),
97        "integrity" | "sfc" | "dism" | "system_health_deep" => inspect_integrity(),
98        "domain" | "active_directory" | "ad_context" | "workgroup" => inspect_domain(),
99        "device_health" | "hardware_errors" | "yellow_bangs" => inspect_device_health(),
100        "drivers" | "system_drivers" | "driver_list" => inspect_drivers(max_entries),
101        "peripherals" | "usb" | "input_devices" | "connected_hardware" => inspect_peripherals(max_entries),
102        "sessions" | "logins" | "active_sessions" => inspect_sessions(max_entries),
103        "repo_doctor" => {
104            let path = resolve_optional_path(args)?;
105            inspect_repo_doctor(path, max_entries)
106        }
107        "directory" => {
108            let raw_path = args
109                .get("path")
110                .and_then(|v| v.as_str())
111                .ok_or_else(|| {
112                    "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
113                        .to_string()
114                })?;
115            let resolved = resolve_path(raw_path)?;
116            inspect_directory("Directory", resolved, max_entries).await
117        }
118        "disk_benchmark" | "stress_test" | "io_intensity" => {
119            let path = resolve_optional_path(args)?;
120            inspect_disk_benchmark(path).await
121        }
122        "permissions" | "acl" | "access_control" => {
123            let path = resolve_optional_path(args)?;
124            inspect_permissions(path, max_entries)
125        }
126        "login_history" | "logon_history" | "user_logins" => {
127            inspect_login_history(max_entries)
128        }
129        "share_access" | "unc_access" | "remote_share" => {
130            let path = resolve_path(args.get("path").and_then(|v| v.as_str()).unwrap_or(""))?;
131            inspect_share_access(path)
132        }
133        "registry_audit" | "persistence" | "integrity_audit" => inspect_registry_audit(),
134        "thermal" | "throttling" | "overheating" => inspect_thermal(),
135        "activation" | "license_status" | "slmgr" => inspect_activation(),
136        "patch_history" | "hotfixes" | "recent_patches" => inspect_patch_history(max_entries),
137        "ad_user" | "ad" | "domain_user" => {
138            let identity = parse_name_filter(args).unwrap_or_default();
139            inspect_ad_user(&identity)
140        }
141        "dns_lookup" | "dig" | "nslookup" => {
142            let name = parse_name_filter(args).unwrap_or_default();
143            let record_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("SRV");
144            inspect_dns_lookup(&name, record_type)
145        }
146        "hyperv" | "hyper-v" | "vms" => inspect_hyperv(),
147        "ip_config" | "ip_detail" | "dhcp" => inspect_ip_config(),
148        other => Err(format!(
149            "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, services, processes, desktop, downloads, directory, disk_benchmark, disk, ports, repo_doctor, log_check, startup_items, health_report, storage, hardware, updates, security, pending_reboot, disk_health, battery, recent_crashes, scheduled_tasks, dev_conflicts, connectivity, wifi, connections, vpn, proxy, firewall_rules, traceroute, dns_cache, arp, route_table, os_config, resource_load, env, hosts_file, docker, wsl, ssh, installed_software, git_config, databases, user_accounts, audit_policy, shares, dns_servers, bitlocker, rdp, shadow_copies, pagefile, windows_features, printers, winrm, network_stats, udp_ports, gpo, certificates, integrity, domain, device_health, drivers, peripherals, sessions, permissions, login_history, share_access, registry_audit, thermal, activation, patch_history, ad_user, dns_lookup, hyperv, ip_config.",
150            other
151        )),
152
153    }
154}
155
156fn parse_max_entries(args: &Value) -> usize {
157    args.get("max_entries")
158        .and_then(|v| v.as_u64())
159        .map(|n| n as usize)
160        .unwrap_or(DEFAULT_MAX_ENTRIES)
161        .clamp(1, MAX_ENTRIES_CAP)
162}
163
164fn parse_port_filter(args: &Value) -> Option<u16> {
165    args.get("port")
166        .and_then(|v| v.as_u64())
167        .and_then(|n| u16::try_from(n).ok())
168}
169
170fn parse_name_filter(args: &Value) -> Option<String> {
171    args.get("name")
172        .and_then(|v| v.as_str())
173        .map(str::trim)
174        .filter(|value| !value.is_empty())
175        .map(|value| value.to_string())
176}
177
178fn parse_lookback_hours(args: &Value) -> Option<u32> {
179    args.get("lookback_hours")
180        .and_then(|v| v.as_u64())
181        .map(|n| n as u32)
182}
183
184fn parse_issue_text(args: &Value) -> Option<String> {
185    args.get("issue")
186        .and_then(|v| v.as_str())
187        .map(str::trim)
188        .filter(|value| !value.is_empty())
189        .map(|value| value.to_string())
190}
191
192fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
193    match args.get("path").and_then(|v| v.as_str()) {
194        Some(raw_path) => resolve_path(raw_path),
195        None => {
196            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
197        }
198    }
199}
200
201fn inspect_summary(max_entries: usize) -> Result<String, String> {
202    let current_dir =
203        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
204    let workspace_root = crate::tools::file_ops::workspace_root();
205    let workspace_mode = workspace_mode_label(&workspace_root);
206    let path_stats = analyze_path_env();
207    let toolchains = collect_toolchains();
208
209    let mut out = String::from("Host inspection: summary\n\n");
210    out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
211    out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
212    out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
213    out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
214    out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
215    out.push_str(&format!(
216        "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
217        path_stats.total_entries,
218        path_stats.unique_entries,
219        path_stats.duplicate_entries.len(),
220        path_stats.missing_entries.len()
221    ));
222
223    if toolchains.found.is_empty() {
224        out.push_str(
225            "- Toolchains found: none of the common developer tools were detected on PATH\n",
226        );
227    } else {
228        out.push_str("- Toolchains found:\n");
229        for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
230            out.push_str(&format!("  - {}: {}\n", label, version));
231        }
232        if toolchains.found.len() > max_entries.min(8) {
233            out.push_str(&format!(
234                "  - ... {} more found tools omitted\n",
235                toolchains.found.len() - max_entries.min(8)
236            ));
237        }
238    }
239
240    if !toolchains.missing.is_empty() {
241        out.push_str(&format!(
242            "- Common tools not detected on PATH: {}\n",
243            toolchains.missing.join(", ")
244        ));
245    }
246
247    for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
248        match path {
249            Some(path) if path.exists() => match count_top_level_items(&path) {
250                Ok(count) => out.push_str(&format!(
251                    "- {}: {} top-level items at {}\n",
252                    label,
253                    count,
254                    path.display()
255                )),
256                Err(e) => out.push_str(&format!(
257                    "- {}: exists at {} but could not inspect ({})\n",
258                    label,
259                    path.display(),
260                    e
261                )),
262            },
263            Some(path) => out.push_str(&format!(
264                "- {}: expected at {} but not found\n",
265                label,
266                path.display()
267            )),
268            None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
269        }
270    }
271
272    Ok(out.trim_end().to_string())
273}
274
275fn inspect_toolchains() -> Result<String, String> {
276    let report = collect_toolchains();
277    let mut out = String::from("Host inspection: toolchains\n\n");
278
279    if report.found.is_empty() {
280        out.push_str("- No common developer tools were detected on PATH.");
281    } else {
282        out.push_str("Detected developer tools:\n");
283        for (label, version) in report.found {
284            out.push_str(&format!("- {}: {}\n", label, version));
285        }
286    }
287
288    if !report.missing.is_empty() {
289        out.push_str("\nNot detected on PATH:\n");
290        for label in report.missing {
291            out.push_str(&format!("- {}\n", label));
292        }
293    }
294
295    Ok(out.trim_end().to_string())
296}
297
298fn inspect_path(max_entries: usize) -> Result<String, String> {
299    let path_stats = analyze_path_env();
300    let mut out = String::from("Host inspection: PATH\n\n");
301    out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
302    out.push_str(&format!(
303        "- Unique entries: {}\n",
304        path_stats.unique_entries
305    ));
306    out.push_str(&format!(
307        "- Duplicate entries: {}\n",
308        path_stats.duplicate_entries.len()
309    ));
310    out.push_str(&format!(
311        "- Missing paths: {}\n",
312        path_stats.missing_entries.len()
313    ));
314
315    out.push_str("\nPATH entries:\n");
316    for entry in path_stats.entries.iter().take(max_entries) {
317        out.push_str(&format!("- {}\n", entry));
318    }
319    if path_stats.entries.len() > max_entries {
320        out.push_str(&format!(
321            "- ... {} more entries omitted\n",
322            path_stats.entries.len() - max_entries
323        ));
324    }
325
326    if !path_stats.duplicate_entries.is_empty() {
327        out.push_str("\nDuplicate entries:\n");
328        for entry in path_stats.duplicate_entries.iter().take(max_entries) {
329            out.push_str(&format!("- {}\n", entry));
330        }
331        if path_stats.duplicate_entries.len() > max_entries {
332            out.push_str(&format!(
333                "- ... {} more duplicates omitted\n",
334                path_stats.duplicate_entries.len() - max_entries
335            ));
336        }
337    }
338
339    if !path_stats.missing_entries.is_empty() {
340        out.push_str("\nMissing directories:\n");
341        for entry in path_stats.missing_entries.iter().take(max_entries) {
342            out.push_str(&format!("- {}\n", entry));
343        }
344        if path_stats.missing_entries.len() > max_entries {
345            out.push_str(&format!(
346                "- ... {} more missing entries omitted\n",
347                path_stats.missing_entries.len() - max_entries
348            ));
349        }
350    }
351
352    Ok(out.trim_end().to_string())
353}
354
355fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
356    let path_stats = analyze_path_env();
357    let toolchains = collect_toolchains();
358    let package_managers = collect_package_managers();
359    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
360
361    let mut out = String::from("Host inspection: env_doctor\n\n");
362    out.push_str(&format!(
363        "- PATH health: {} duplicates, {} missing entries\n",
364        path_stats.duplicate_entries.len(),
365        path_stats.missing_entries.len()
366    ));
367    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
368    out.push_str(&format!(
369        "- Package managers found: {}\n",
370        package_managers.found.len()
371    ));
372
373    if !package_managers.found.is_empty() {
374        out.push_str("\nPackage managers:\n");
375        for (label, version) in package_managers.found.iter().take(max_entries) {
376            out.push_str(&format!("- {}: {}\n", label, version));
377        }
378        if package_managers.found.len() > max_entries {
379            out.push_str(&format!(
380                "- ... {} more package managers omitted\n",
381                package_managers.found.len() - max_entries
382            ));
383        }
384    }
385
386    if !path_stats.duplicate_entries.is_empty() {
387        out.push_str("\nDuplicate PATH entries:\n");
388        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
389            out.push_str(&format!("- {}\n", entry));
390        }
391        if path_stats.duplicate_entries.len() > max_entries.min(5) {
392            out.push_str(&format!(
393                "- ... {} more duplicate entries omitted\n",
394                path_stats.duplicate_entries.len() - max_entries.min(5)
395            ));
396        }
397    }
398
399    if !path_stats.missing_entries.is_empty() {
400        out.push_str("\nMissing PATH entries:\n");
401        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
402            out.push_str(&format!("- {}\n", entry));
403        }
404        if path_stats.missing_entries.len() > max_entries.min(5) {
405            out.push_str(&format!(
406                "- ... {} more missing entries omitted\n",
407                path_stats.missing_entries.len() - max_entries.min(5)
408            ));
409        }
410    }
411
412    if !findings.is_empty() {
413        out.push_str("\nFindings:\n");
414        for finding in findings.iter().take(max_entries.max(5)) {
415            out.push_str(&format!("- {}\n", finding));
416        }
417        if findings.len() > max_entries.max(5) {
418            out.push_str(&format!(
419                "- ... {} more findings omitted\n",
420                findings.len() - max_entries.max(5)
421            ));
422        }
423    } else {
424        out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
425    }
426
427    out.push_str(
428        "\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.",
429    );
430
431    Ok(out.trim_end().to_string())
432}
433
434#[derive(Clone, Copy, Debug, Eq, PartialEq)]
435enum FixPlanKind {
436    EnvPath,
437    PortConflict,
438    LmStudio,
439    DriverInstall,
440    GroupPolicy,
441    FirewallRule,
442    SshKey,
443    WslSetup,
444    ServiceConfig,
445    WindowsActivation,
446    RegistryEdit,
447    ScheduledTaskCreate,
448    DiskCleanup,
449    DnsResolution,
450    Generic,
451}
452
453async fn inspect_fix_plan(
454    issue: Option<String>,
455    port_filter: Option<u16>,
456    max_entries: usize,
457) -> Result<String, String> {
458    let issue = issue.unwrap_or_else(|| {
459        "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
460            .to_string()
461    });
462    let plan_kind = classify_fix_plan_kind(&issue, port_filter);
463    match plan_kind {
464        FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
465        FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
466        FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
467        FixPlanKind::DriverInstall => inspect_driver_install_fix_plan(&issue),
468        FixPlanKind::GroupPolicy => inspect_group_policy_fix_plan(&issue),
469        FixPlanKind::FirewallRule => inspect_firewall_rule_fix_plan(&issue),
470        FixPlanKind::SshKey => inspect_ssh_key_fix_plan(&issue),
471        FixPlanKind::WslSetup => inspect_wsl_setup_fix_plan(&issue),
472        FixPlanKind::ServiceConfig => inspect_service_config_fix_plan(&issue),
473        FixPlanKind::WindowsActivation => inspect_windows_activation_fix_plan(&issue),
474        FixPlanKind::RegistryEdit => inspect_registry_edit_fix_plan(&issue),
475        FixPlanKind::ScheduledTaskCreate => inspect_scheduled_task_fix_plan(&issue),
476        FixPlanKind::DiskCleanup => inspect_disk_cleanup_fix_plan(&issue),
477        FixPlanKind::DnsResolution => inspect_dns_fix_plan(&issue),
478        FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
479    }
480}
481
482fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
483    let lower = issue.to_ascii_lowercase();
484    // FirewallRule must be checked before PortConflict — "open port 80 in the firewall"
485    // is firewall rule creation, not a port ownership conflict.
486    if lower.contains("firewall rule")
487        || lower.contains("inbound rule")
488        || lower.contains("outbound rule")
489        || (lower.contains("firewall")
490            && (lower.contains("allow")
491                || lower.contains("block")
492                || lower.contains("create")
493                || lower.contains("open")))
494    {
495        FixPlanKind::FirewallRule
496    } else if port_filter.is_some()
497        || lower.contains("port ")
498        || lower.contains("address already in use")
499        || lower.contains("already in use")
500        || lower.contains("what owns port")
501        || lower.contains("listening on port")
502    {
503        FixPlanKind::PortConflict
504    } else if lower.contains("lm studio")
505        || lower.contains("localhost:1234")
506        || lower.contains("/v1/models")
507        || lower.contains("no coding model loaded")
508        || lower.contains("embedding model")
509        || lower.contains("server on port 1234")
510        || lower.contains("runtime refresh")
511    {
512        FixPlanKind::LmStudio
513    } else if lower.contains("driver")
514        || lower.contains("gpu driver")
515        || lower.contains("nvidia driver")
516        || lower.contains("amd driver")
517        || lower.contains("install driver")
518        || lower.contains("update driver")
519    {
520        FixPlanKind::DriverInstall
521    } else if lower.contains("group policy")
522        || lower.contains("gpedit")
523        || lower.contains("local policy")
524        || lower.contains("secpol")
525        || lower.contains("administrative template")
526    {
527        FixPlanKind::GroupPolicy
528    } else if lower.contains("ssh key")
529        || lower.contains("ssh-keygen")
530        || lower.contains("generate ssh")
531        || lower.contains("authorized_keys")
532        || lower.contains("id_rsa")
533        || lower.contains("id_ed25519")
534    {
535        FixPlanKind::SshKey
536    } else if lower.contains("wsl")
537        || lower.contains("windows subsystem for linux")
538        || lower.contains("install ubuntu")
539        || lower.contains("install linux on windows")
540        || lower.contains("wsl2")
541    {
542        FixPlanKind::WslSetup
543    } else if lower.contains("service")
544        && (lower.contains("start ")
545            || lower.contains("stop ")
546            || lower.contains("restart ")
547            || lower.contains("enable ")
548            || lower.contains("disable ")
549            || lower.contains("configure service"))
550    {
551        FixPlanKind::ServiceConfig
552    } else if lower.contains("activate windows")
553        || lower.contains("windows activation")
554        || lower.contains("product key")
555        || lower.contains("kms")
556        || lower.contains("not activated")
557    {
558        FixPlanKind::WindowsActivation
559    } else if lower.contains("registry")
560        || lower.contains("regedit")
561        || lower.contains("hklm")
562        || lower.contains("hkcu")
563        || lower.contains("reg add")
564        || lower.contains("reg delete")
565        || lower.contains("registry key")
566    {
567        FixPlanKind::RegistryEdit
568    } else if lower.contains("scheduled task")
569        || lower.contains("task scheduler")
570        || lower.contains("schtasks")
571        || lower.contains("create task")
572        || lower.contains("run on startup")
573        || lower.contains("run on schedule")
574        || lower.contains("cron")
575    {
576        FixPlanKind::ScheduledTaskCreate
577    } else if lower.contains("disk cleanup")
578        || lower.contains("free up disk")
579        || lower.contains("free up space")
580        || lower.contains("clear cache")
581        || lower.contains("disk full")
582        || lower.contains("low disk space")
583        || lower.contains("reclaim space")
584    {
585        FixPlanKind::DiskCleanup
586    } else if lower.contains("cargo")
587        || lower.contains("rustc")
588        || lower.contains("path")
589        || lower.contains("package manager")
590        || lower.contains("package managers")
591        || lower.contains("toolchain")
592        || lower.contains("winget")
593        || lower.contains("choco")
594        || lower.contains("scoop")
595        || lower.contains("python")
596        || lower.contains("node")
597    {
598        FixPlanKind::EnvPath
599    } else if lower.contains("dns ")
600        || lower.contains("nameserver")
601        || lower.contains("cannot resolve")
602        || lower.contains("nslookup")
603        || lower.contains("flushdns")
604    {
605        FixPlanKind::DnsResolution
606    } else {
607        FixPlanKind::Generic
608    }
609}
610
611fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
612    let path_stats = analyze_path_env();
613    let toolchains = collect_toolchains();
614    let package_managers = collect_package_managers();
615    let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
616    let found_tools = toolchains
617        .found
618        .iter()
619        .map(|(label, _)| label.as_str())
620        .collect::<HashSet<_>>();
621    let found_managers = package_managers
622        .found
623        .iter()
624        .map(|(label, _)| label.as_str())
625        .collect::<HashSet<_>>();
626
627    let mut out = String::from("Host inspection: fix_plan\n\n");
628    out.push_str(&format!("- Requested issue: {}\n", issue));
629    out.push_str("- Fix-plan type: environment/path\n");
630    out.push_str(&format!(
631        "- PATH health: {} duplicates, {} missing entries\n",
632        path_stats.duplicate_entries.len(),
633        path_stats.missing_entries.len()
634    ));
635    out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
636    out.push_str(&format!(
637        "- Package managers found: {}\n",
638        package_managers.found.len()
639    ));
640
641    out.push_str("\nLikely causes:\n");
642    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
643        out.push_str(
644            "- 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",
645        );
646    }
647    if path_stats.duplicate_entries.is_empty()
648        && path_stats.missing_entries.is_empty()
649        && !findings.is_empty()
650    {
651        for finding in findings.iter().take(max_entries.max(4)) {
652            out.push_str(&format!("- {}\n", finding));
653        }
654    } else {
655        if !path_stats.duplicate_entries.is_empty() {
656            out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
657        }
658        if !path_stats.missing_entries.is_empty() {
659            out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
660        }
661    }
662    if found_tools.contains("node")
663        && !found_managers.contains("npm")
664        && !found_managers.contains("pnpm")
665    {
666        out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
667    }
668    if found_tools.contains("python")
669        && !found_managers.contains("pip")
670        && !found_managers.contains("uv")
671        && !found_managers.contains("pipx")
672    {
673        out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
674    }
675
676    out.push_str("\nFix plan:\n");
677    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");
678    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
679        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");
680    } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
681        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");
682    }
683    if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
684        out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
685    }
686    if found_tools.contains("node")
687        && !found_managers.contains("npm")
688        && !found_managers.contains("pnpm")
689    {
690        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");
691    }
692    if found_tools.contains("python")
693        && !found_managers.contains("pip")
694        && !found_managers.contains("uv")
695        && !found_managers.contains("pipx")
696    {
697        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");
698    }
699
700    if !path_stats.duplicate_entries.is_empty() {
701        out.push_str("\nExample duplicate PATH rows:\n");
702        for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
703            out.push_str(&format!("- {}\n", entry));
704        }
705    }
706    if !path_stats.missing_entries.is_empty() {
707        out.push_str("\nExample missing PATH rows:\n");
708        for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
709            out.push_str(&format!("- {}\n", entry));
710        }
711    }
712
713    out.push_str(
714        "\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.",
715    );
716    Ok(out.trim_end().to_string())
717}
718
719fn inspect_port_fix_plan(
720    issue: &str,
721    port_filter: Option<u16>,
722    max_entries: usize,
723) -> Result<String, String> {
724    let requested_port = port_filter.or_else(|| first_port_in_text(issue));
725    let listeners = collect_listening_ports().unwrap_or_default();
726    let mut matching = listeners;
727    if let Some(port) = requested_port {
728        matching.retain(|entry| entry.port == port);
729    }
730    let processes = collect_processes().unwrap_or_default();
731
732    let mut out = String::from("Host inspection: fix_plan\n\n");
733    out.push_str(&format!("- Requested issue: {}\n", issue));
734    out.push_str("- Fix-plan type: port_conflict\n");
735    if let Some(port) = requested_port {
736        out.push_str(&format!("- Requested port: {}\n", port));
737    } else {
738        out.push_str("- Requested port: not parsed from the issue text\n");
739    }
740    out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
741
742    if !matching.is_empty() {
743        out.push_str("\nCurrent listeners:\n");
744        for entry in matching.iter().take(max_entries.min(5)) {
745            let process_name = entry
746                .pid
747                .as_deref()
748                .and_then(|pid| pid.parse::<u32>().ok())
749                .and_then(|pid| {
750                    processes
751                        .iter()
752                        .find(|process| process.pid == pid)
753                        .map(|process| process.name.as_str())
754                })
755                .unwrap_or("unknown");
756            let pid = entry.pid.as_deref().unwrap_or("unknown");
757            out.push_str(&format!(
758                "- {} {} ({}) pid {} process {}\n",
759                entry.protocol, entry.local, entry.state, pid, process_name
760            ));
761        }
762    }
763
764    out.push_str("\nFix plan:\n");
765    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");
766    if !matching.is_empty() {
767        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");
768    } else {
769        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");
770    }
771    out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
772    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");
773    out.push_str(
774        "\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.",
775    );
776    Ok(out.trim_end().to_string())
777}
778
779async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
780    let config = crate::agent::config::load_config();
781    let configured_api = config
782        .api_url
783        .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
784    let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
785    let reachability = probe_http_endpoint(&models_url).await;
786    let embed_model = detect_loaded_embed_model(&configured_api).await;
787
788    let mut out = String::from("Host inspection: fix_plan\n\n");
789    out.push_str(&format!("- Requested issue: {}\n", issue));
790    out.push_str("- Fix-plan type: lm_studio\n");
791    out.push_str(&format!("- Configured API URL: {}\n", configured_api));
792    out.push_str(&format!("- Probe URL: {}\n", models_url));
793    match &reachability {
794        EndpointProbe::Reachable(status) => {
795            out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
796        }
797        EndpointProbe::Unreachable(detail) => {
798            out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
799        }
800    }
801    out.push_str(&format!(
802        "- Embedding model loaded: {}\n",
803        embed_model.as_deref().unwrap_or("none detected")
804    ));
805
806    out.push_str("\nFix plan:\n");
807    match reachability {
808        EndpointProbe::Reachable(_) => {
809            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");
810        }
811        EndpointProbe::Unreachable(_) => {
812            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");
813        }
814    }
815    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");
816    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");
817    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");
818    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");
819    if let Some(model) = embed_model {
820        out.push_str(&format!(
821            "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
822            model
823        ));
824    }
825    if max_entries > 0 {
826        out.push_str(
827            "\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.",
828        );
829    }
830    Ok(out.trim_end().to_string())
831}
832
833fn inspect_driver_install_fix_plan(issue: &str) -> Result<String, String> {
834    // Read GPU info from the hardware topic output for grounding
835    #[cfg(target_os = "windows")]
836    let gpu_info = {
837        let out = Command::new("powershell")
838            .args([
839                "-NoProfile",
840                "-NonInteractive",
841                "-Command",
842                "Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate | ForEach-Object { \"GPU: $($_.Name) | Driver: $($_.DriverVersion) | Date: $($_.DriverDate)\" }",
843            ])
844            .output()
845            .ok()
846            .and_then(|o| String::from_utf8(o.stdout).ok())
847            .unwrap_or_default();
848        out.trim().to_string()
849    };
850    #[cfg(not(target_os = "windows"))]
851    let gpu_info = String::from("(GPU detection not available on this platform)");
852
853    let mut out = String::from("Host inspection: fix_plan\n\n");
854    out.push_str(&format!("- Requested issue: {}\n", issue));
855    out.push_str("- Fix-plan type: driver_install\n");
856    if !gpu_info.is_empty() {
857        out.push_str(&format!("\nDetected GPU(s):\n{}\n", gpu_info));
858    }
859    out.push_str("\nFix plan — Installing or updating GPU drivers:\n");
860    out.push_str("1. Identify your GPU make from the detection above (NVIDIA, AMD, or Intel).\n");
861    out.push_str(
862        "2. Open Device Manager: press Win+X → Device Manager → expand Display Adapters.\n",
863    );
864    out.push_str("3. Right-click your GPU → Properties → Driver tab — note the current driver version and date.\n");
865    out.push_str("4. Download the latest driver directly from the manufacturer:\n");
866    out.push_str("   - NVIDIA: geforce.com/drivers (use GeForce Experience for auto-detection)\n");
867    out.push_str("   - AMD: amd.com/support (use Auto-Detect tool)\n");
868    out.push_str("   - Intel: intel.com/content/www/us/en/download-center/home.html\n");
869    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");
870    out.push_str("6. Reboot when prompted — driver installs always require a restart.\n");
871    out.push_str("\nVerification:\n");
872    out.push_str("- After reboot, run in PowerShell:\n  Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,DriverDate\n");
873    out.push_str("- The DriverVersion should match what you installed.\n");
874    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.");
875    Ok(out.trim_end().to_string())
876}
877
878fn inspect_group_policy_fix_plan(issue: &str) -> Result<String, String> {
879    // Check Windows edition — Group Policy editor is not available on Home editions
880    #[cfg(target_os = "windows")]
881    let edition = {
882        Command::new("powershell")
883            .args([
884                "-NoProfile",
885                "-NonInteractive",
886                "-Command",
887                "(Get-CimInstance Win32_OperatingSystem).Caption",
888            ])
889            .output()
890            .ok()
891            .and_then(|o| String::from_utf8(o.stdout).ok())
892            .unwrap_or_default()
893            .trim()
894            .to_string()
895    };
896    #[cfg(not(target_os = "windows"))]
897    let edition = String::from("(Windows edition detection not available)");
898
899    let is_home = edition.to_lowercase().contains("home");
900
901    let mut out = String::from("Host inspection: fix_plan\n\n");
902    out.push_str(&format!("- Requested issue: {}\n", issue));
903    out.push_str("- Fix-plan type: group_policy\n");
904    out.push_str(&format!(
905        "- Windows edition detected: {}\n",
906        if edition.is_empty() {
907            "unknown".to_string()
908        } else {
909            edition.clone()
910        }
911    ));
912
913    if is_home {
914        out.push_str("\nWARNING: Windows Home does not include the Local Group Policy Editor (gpedit.msc).\n");
915        out.push_str("Options on Home edition:\n");
916        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");
917        out.push_str(
918            "2. Install the gpedit.msc enabler script (third-party — use with caution).\n",
919        );
920        out.push_str("3. Upgrade to Windows Pro if you need full Group Policy support.\n");
921    } else {
922        out.push_str("\nFix plan — Editing Local Group Policy:\n");
923        out.push_str("1. Press Win+R → type gpedit.msc → press Enter (requires administrator).\n");
924        out.push_str("2. Navigate the tree: Computer Configuration (machine-wide) or User Configuration (current user).\n");
925        out.push_str("3. Drill into Administrative Templates → find the policy you want.\n");
926        out.push_str("4. Double-click a policy → set to Enabled, Disabled, or Not Configured.\n");
927        out.push_str("5. Click OK — most policies apply on next logon or after gpupdate.\n");
928        out.push_str("6. To force immediate application, run in an elevated PowerShell:\n  gpupdate /force\n");
929    }
930    out.push_str("\nVerification:\n");
931    out.push_str("- Run `gpresult /r` in an elevated command prompt to see applied policies.\n");
932    out.push_str(
933        "- Or: `Get-GPResultantSetOfPolicy` in PowerShell (requires RSAT on domain machines).\n",
934    );
935    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.");
936    Ok(out.trim_end().to_string())
937}
938
939fn inspect_firewall_rule_fix_plan(issue: &str) -> Result<String, String> {
940    #[cfg(target_os = "windows")]
941    let profile_state = {
942        Command::new("powershell")
943            .args([
944                "-NoProfile",
945                "-NonInteractive",
946                "-Command",
947                "Get-NetFirewallProfile | Select-Object Name,Enabled | ForEach-Object { \"$($_.Name): $($_.Enabled)\" }",
948            ])
949            .output()
950            .ok()
951            .and_then(|o| String::from_utf8(o.stdout).ok())
952            .unwrap_or_default()
953            .trim()
954            .to_string()
955    };
956    #[cfg(not(target_os = "windows"))]
957    let profile_state = String::new();
958
959    let mut out = String::from("Host inspection: fix_plan\n\n");
960    out.push_str(&format!("- Requested issue: {}\n", issue));
961    out.push_str("- Fix-plan type: firewall_rule\n");
962    if !profile_state.is_empty() {
963        out.push_str(&format!("\nFirewall profile state:\n{}\n", profile_state));
964    }
965    out.push_str("\nFix plan — Creating or modifying a Windows Firewall rule (PowerShell, run as Administrator):\n");
966    out.push_str("\nTo ALLOW inbound traffic on a port:\n");
967    out.push_str("  New-NetFirewallRule -DisplayName \"My App Port 8080\" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Any\n");
968    out.push_str("\nTo BLOCK outbound traffic to an address:\n");
969    out.push_str("  New-NetFirewallRule -DisplayName \"Block Example\" -Direction Outbound -RemoteAddress 1.2.3.4 -Action Block\n");
970    out.push_str("\nTo ALLOW an application through the firewall:\n");
971    out.push_str("  New-NetFirewallRule -DisplayName \"My App\" -Direction Inbound -Program \"C:\\Path\\To\\App.exe\" -Action Allow\n");
972    out.push_str("\nTo REMOVE a rule you created:\n");
973    out.push_str("  Remove-NetFirewallRule -DisplayName \"My App Port 8080\"\n");
974    out.push_str("\nTo see existing custom rules:\n");
975    out.push_str("  Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.PolicyStoreSourceType -ne 'GroupPolicy' } | Select-Object DisplayName,Direction,Action\n");
976    out.push_str("\nVerification:\n");
977    out.push_str("- After creating the rule, test reachability from another machine or use:\n  Test-NetConnection -ComputerName localhost -Port 8080\n");
978    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.");
979    Ok(out.trim_end().to_string())
980}
981
982fn inspect_ssh_key_fix_plan(issue: &str) -> Result<String, String> {
983    let home = dirs_home().unwrap_or_else(|| std::path::PathBuf::from("~"));
984    let ssh_dir = home.join(".ssh");
985    let has_ssh_dir = ssh_dir.exists();
986    let has_ed25519 = ssh_dir.join("id_ed25519").exists();
987    let has_rsa = ssh_dir.join("id_rsa").exists();
988    let has_authorized_keys = ssh_dir.join("authorized_keys").exists();
989
990    let mut out = String::from("Host inspection: fix_plan\n\n");
991    out.push_str(&format!("- Requested issue: {}\n", issue));
992    out.push_str("- Fix-plan type: ssh_key\n");
993    out.push_str(&format!("- ~/.ssh directory exists: {}\n", has_ssh_dir));
994    out.push_str(&format!("- id_ed25519 key found: {}\n", has_ed25519));
995    out.push_str(&format!("- id_rsa key found: {}\n", has_rsa));
996    out.push_str(&format!(
997        "- authorized_keys found: {}\n",
998        has_authorized_keys
999    ));
1000
1001    if has_ed25519 {
1002        out.push_str("\nYou already have an Ed25519 key. If you want to use it, skip to the 'Add to agent' step.\n");
1003    }
1004
1005    out.push_str("\nFix plan — Generating an SSH key pair:\n");
1006    out.push_str("1. Open PowerShell (or Terminal) — no elevation needed.\n");
1007    out.push_str("2. Generate an Ed25519 key (preferred over RSA):\n");
1008    out.push_str("   ssh-keygen -t ed25519 -C \"your@email.com\"\n");
1009    out.push_str(
1010        "   - Accept the default path (~/.ssh/id_ed25519) unless you need a custom name.\n",
1011    );
1012    out.push_str("   - Set a passphrase (recommended) or press Enter twice for no passphrase.\n");
1013    out.push_str("3. Start the SSH agent and add your key:\n");
1014    out.push_str("   # Windows (PowerShell, run as Admin once to enable the service):\n");
1015    out.push_str("   Set-Service -Name ssh-agent -StartupType Automatic\n");
1016    out.push_str("   Start-Service ssh-agent\n");
1017    out.push_str("   # Then add the key (normal PowerShell):\n");
1018    out.push_str("   ssh-add ~/.ssh/id_ed25519\n");
1019    out.push_str("4. Copy your PUBLIC key to the target server's authorized_keys:\n");
1020    out.push_str("   # Print your public key:\n");
1021    out.push_str("   cat ~/.ssh/id_ed25519.pub\n");
1022    out.push_str("   # On the target server, append it:\n");
1023    out.push_str("   echo \"<paste public key>\" >> ~/.ssh/authorized_keys\n");
1024    out.push_str("   chmod 600 ~/.ssh/authorized_keys\n");
1025    out.push_str("5. Test the connection:\n");
1026    out.push_str("   ssh user@server-address\n");
1027    out.push_str("\nFor GitHub/GitLab:\n");
1028    out.push_str("- Copy the public key: Get-Content ~/.ssh/id_ed25519.pub | Set-Clipboard\n");
1029    out.push_str("- Paste it into GitHub Settings → SSH and GPG keys → New SSH key\n");
1030    out.push_str("- Test: ssh -T git@github.com\n");
1031    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.");
1032    Ok(out.trim_end().to_string())
1033}
1034
1035fn inspect_wsl_setup_fix_plan(issue: &str) -> Result<String, String> {
1036    #[cfg(target_os = "windows")]
1037    let wsl_status = {
1038        let out = Command::new("wsl")
1039            .args(["--status"])
1040            .output()
1041            .ok()
1042            .and_then(|o| {
1043                let stdout = String::from_utf8(o.stdout).unwrap_or_default();
1044                let stderr = String::from_utf8(o.stderr).unwrap_or_default();
1045                Some(format!("{}{}", stdout, stderr))
1046            })
1047            .unwrap_or_default();
1048        out.trim().to_string()
1049    };
1050    #[cfg(not(target_os = "windows"))]
1051    let wsl_status = String::new();
1052
1053    let wsl_installed =
1054        !wsl_status.is_empty() && !wsl_status.to_lowercase().contains("not installed");
1055
1056    let mut out = String::from("Host inspection: fix_plan\n\n");
1057    out.push_str(&format!("- Requested issue: {}\n", issue));
1058    out.push_str("- Fix-plan type: wsl_setup\n");
1059    out.push_str(&format!("- WSL already installed: {}\n", wsl_installed));
1060    if !wsl_status.is_empty() {
1061        out.push_str(&format!("- WSL status:\n{}\n", wsl_status));
1062    }
1063
1064    if wsl_installed {
1065        out.push_str("\nWSL is already installed. To install a new Linux distro:\n");
1066        out.push_str("1. Run in PowerShell (Admin): wsl --install -d Ubuntu\n");
1067        out.push_str("   Available distros: wsl --list --online\n");
1068        out.push_str("2. After install, launch from Start menu or type 'ubuntu' in PowerShell.\n");
1069        out.push_str("3. Create your Linux username and password when prompted.\n");
1070    } else {
1071        out.push_str("\nFix plan — Installing WSL2 (Windows Subsystem for Linux):\n");
1072        out.push_str("1. Open PowerShell as Administrator.\n");
1073        out.push_str("2. Install WSL with the default Ubuntu distro:\n");
1074        out.push_str("   wsl --install\n");
1075        out.push_str("   (This enables the required Windows features, downloads WSL2, and installs Ubuntu)\n");
1076        out.push_str("3. Reboot when prompted — WSL requires a restart after the first install.\n");
1077        out.push_str("4. After reboot, Ubuntu will launch automatically and ask you to create a username and password.\n");
1078        out.push_str("5. Set WSL2 as the default version (should already be set, but confirm):\n");
1079        out.push_str("   wsl --set-default-version 2\n");
1080        out.push_str("\nTo install a different distro instead of Ubuntu:\n");
1081        out.push_str("   wsl --install -d Debian\n");
1082        out.push_str("   wsl --list --online   # to see all available distros\n");
1083    }
1084    out.push_str("\nVerification:\n");
1085    out.push_str("- Run: wsl --list --verbose\n");
1086    out.push_str("- You should see your distro with State: Running and Version: 2\n");
1087    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.");
1088    Ok(out.trim_end().to_string())
1089}
1090
1091fn inspect_service_config_fix_plan(issue: &str) -> Result<String, String> {
1092    let lower = issue.to_ascii_lowercase();
1093    // Extract service name hints from the issue text
1094    let service_hint = if lower.contains("ssh") {
1095        Some("sshd")
1096    } else if lower.contains("mysql") {
1097        Some("MySQL80")
1098    } else if lower.contains("postgres") || lower.contains("postgresql") {
1099        Some("postgresql")
1100    } else if lower.contains("redis") {
1101        Some("Redis")
1102    } else if lower.contains("nginx") {
1103        Some("nginx")
1104    } else if lower.contains("apache") {
1105        Some("Apache2.4")
1106    } else {
1107        None
1108    };
1109
1110    #[cfg(target_os = "windows")]
1111    let service_state = if let Some(svc) = service_hint {
1112        Command::new("powershell")
1113            .args([
1114                "-NoProfile",
1115                "-NonInteractive",
1116                "-Command",
1117                &format!("Get-Service -Name '{}' -ErrorAction SilentlyContinue | Select-Object Name,Status,StartType | ForEach-Object {{ \"Service: $($_.Name) | Status: $($_.Status) | StartType: $($_.StartType)\" }}", svc),
1118            ])
1119            .output()
1120            .ok()
1121            .and_then(|o| String::from_utf8(o.stdout).ok())
1122            .unwrap_or_default()
1123            .trim()
1124            .to_string()
1125    } else {
1126        String::new()
1127    };
1128    #[cfg(not(target_os = "windows"))]
1129    let service_state = String::new();
1130
1131    let mut out = String::from("Host inspection: fix_plan\n\n");
1132    out.push_str(&format!("- Requested issue: {}\n", issue));
1133    out.push_str("- Fix-plan type: service_config\n");
1134    if let Some(svc) = service_hint {
1135        out.push_str(&format!("- Service detected in request: {}\n", svc));
1136    }
1137    if !service_state.is_empty() {
1138        out.push_str(&format!("- Current state: {}\n", service_state));
1139    }
1140
1141    out.push_str("\nFix plan — Managing Windows services (PowerShell, run as Administrator):\n");
1142    out.push_str("\nStart a service:\n");
1143    out.push_str("  Start-Service -Name \"ServiceName\"\n");
1144    out.push_str("\nStop a service:\n");
1145    out.push_str("  Stop-Service -Name \"ServiceName\"\n");
1146    out.push_str("\nRestart a service:\n");
1147    out.push_str("  Restart-Service -Name \"ServiceName\"\n");
1148    out.push_str("\nEnable a service to start automatically:\n");
1149    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Automatic\n");
1150    out.push_str("\nDisable a service (stops it from auto-starting):\n");
1151    out.push_str("  Set-Service -Name \"ServiceName\" -StartupType Disabled\n");
1152    out.push_str("\nFind the exact service name:\n");
1153    out.push_str("  Get-Service | Where-Object { $_.DisplayName -like '*mysql*' }\n");
1154    out.push_str("\nVerification:\n");
1155    out.push_str("  Get-Service -Name \"ServiceName\" | Select-Object Name,Status,StartType\n");
1156    if let Some(svc) = service_hint {
1157        out.push_str(&format!(
1158            "\nFor your detected service ({}):\n  Get-Service -Name '{}'\n",
1159            svc, svc
1160        ));
1161    }
1162    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.");
1163    Ok(out.trim_end().to_string())
1164}
1165
1166fn inspect_windows_activation_fix_plan(issue: &str) -> Result<String, String> {
1167    #[cfg(target_os = "windows")]
1168    let activation_status = {
1169        Command::new("powershell")
1170            .args([
1171                "-NoProfile",
1172                "-NonInteractive",
1173                "-Command",
1174                "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 + ')' })\" }",
1175            ])
1176            .output()
1177            .ok()
1178            .and_then(|o| String::from_utf8(o.stdout).ok())
1179            .unwrap_or_default()
1180            .trim()
1181            .to_string()
1182    };
1183    #[cfg(not(target_os = "windows"))]
1184    let activation_status = String::new();
1185
1186    let is_licensed = activation_status.to_lowercase().contains("licensed")
1187        && !activation_status.to_lowercase().contains("not licensed");
1188
1189    let mut out = String::from("Host inspection: fix_plan\n\n");
1190    out.push_str(&format!("- Requested issue: {}\n", issue));
1191    out.push_str("- Fix-plan type: windows_activation\n");
1192    if !activation_status.is_empty() {
1193        out.push_str(&format!(
1194            "- Current activation state:\n{}\n",
1195            activation_status
1196        ));
1197    }
1198
1199    if is_licensed {
1200        out.push_str(
1201            "\nWindows appears to be activated. If you are still seeing activation prompts, try:\n",
1202        );
1203        out.push_str("1. Run in elevated PowerShell: slmgr /ato\n");
1204        out.push_str("   (Forces an online activation attempt)\n");
1205        out.push_str("2. Check activation details: slmgr /dli\n");
1206    } else {
1207        out.push_str("\nFix plan — Activating Windows:\n");
1208        out.push_str("1. Check your current status first:\n");
1209        out.push_str("   slmgr /dli   (basic info)\n");
1210        out.push_str("   slmgr /dlv   (detailed — shows remaining rearms, grace period)\n");
1211        out.push_str("\n2. If you have a retail product key:\n");
1212        out.push_str("   slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX   (install key)\n");
1213        out.push_str("   slmgr /ato                                   (activate online)\n");
1214        out.push_str("\n3. If you had a digital license (linked to your Microsoft account):\n");
1215        out.push_str("   - Go to Settings → System → Activation\n");
1216        out.push_str("   - Click 'Troubleshoot' → 'I changed hardware on this device recently'\n");
1217        out.push_str("   - Sign in with the Microsoft account that holds the license\n");
1218        out.push_str("\n4. If using a volume license (organization/enterprise):\n");
1219        out.push_str("   - Contact your IT department for the KMS server address\n");
1220        out.push_str("   - Set KMS host: slmgr /skms kms.yourorg.com\n");
1221        out.push_str("   - Activate:    slmgr /ato\n");
1222    }
1223    out.push_str("\nVerification:\n");
1224    out.push_str("  slmgr /dli   — should show 'License Status: Licensed'\n");
1225    out.push_str("  Or: Settings → System → Activation → 'Windows is activated'\n");
1226    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.");
1227    Ok(out.trim_end().to_string())
1228}
1229
1230fn inspect_registry_edit_fix_plan(issue: &str) -> Result<String, String> {
1231    let mut out = String::from("Host inspection: fix_plan\n\n");
1232    out.push_str(&format!("- Requested issue: {}\n", issue));
1233    out.push_str("- Fix-plan type: registry_edit\n");
1234    out.push_str(
1235        "\nCAUTION: Registry edits affect core Windows behavior. Always back up before editing.\n",
1236    );
1237    out.push_str("\nFix plan — Safely editing the Windows Registry:\n");
1238    out.push_str("\n1. Back up before you touch anything:\n");
1239    out.push_str("   # Export the key you're about to change (PowerShell):\n");
1240    out.push_str("   reg export \"HKLM\\SOFTWARE\\MyKey\" C:\\backup\\MyKey_backup.reg\n");
1241    out.push_str("   # Or export the whole registry (takes a while):\n");
1242    out.push_str("   reg export HKLM C:\\backup\\HKLM_full.reg\n");
1243    out.push_str("\n2. Read a value (PowerShell, no elevation needed for HKCU):\n");
1244    out.push_str("   Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1245    out.push_str("\n3. Create or update a DWORD value (PowerShell, Admin for HKLM):\n");
1246    out.push_str(
1247        "   Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue' -Value 1 -Type DWord\n",
1248    );
1249    out.push_str("\n4. Create a new key:\n");
1250    out.push_str("   New-Item -Path 'HKLM:\\SOFTWARE\\MyNewKey' -Force\n");
1251    out.push_str("\n5. Delete a value:\n");
1252    out.push_str("   Remove-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' -Name 'MyValue'\n");
1253    out.push_str("\n6. Restore from backup if something breaks:\n");
1254    out.push_str("   reg import C:\\backup\\MyKey_backup.reg\n");
1255    out.push_str("\nCommon registry hives:\n");
1256    out.push_str("  HKLM = HKEY_LOCAL_MACHINE  (machine-wide, requires Admin)\n");
1257    out.push_str("  HKCU = HKEY_CURRENT_USER   (current user, no elevation needed)\n");
1258    out.push_str("  HKCR = HKEY_CLASSES_ROOT    (file associations)\n");
1259    out.push_str("\nVerification:\n");
1260    out.push_str("  Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\MyKey' | Select-Object MyValue\n");
1261    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.");
1262    Ok(out.trim_end().to_string())
1263}
1264
1265fn inspect_scheduled_task_fix_plan(issue: &str) -> Result<String, String> {
1266    let mut out = String::from("Host inspection: fix_plan\n\n");
1267    out.push_str(&format!("- Requested issue: {}\n", issue));
1268    out.push_str("- Fix-plan type: scheduled_task_create\n");
1269    out.push_str("\nFix plan — Creating a Scheduled Task (PowerShell, run as Administrator):\n");
1270    out.push_str("\nExample: Run a script at 9 AM every day\n");
1271    out.push_str("  $action  = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-File C:\\Scripts\\MyScript.ps1'\n");
1272    out.push_str("  $trigger = New-ScheduledTaskTrigger -Daily -At '09:00AM'\n");
1273    out.push_str("  Register-ScheduledTask -TaskName 'MyDailyTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1274    out.push_str("\nExample: Run at Windows startup\n");
1275    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtStartup\n");
1276    out.push_str("  Register-ScheduledTask -TaskName 'MyStartupTask' -Action $action -Trigger $trigger -RunLevel Highest\n");
1277    out.push_str("\nExample: Run at user logon\n");
1278    out.push_str("  $trigger = New-ScheduledTaskTrigger -AtLogon\n");
1279    out.push_str(
1280        "  Register-ScheduledTask -TaskName 'MyLogonTask' -Action $action -Trigger $trigger\n",
1281    );
1282    out.push_str("\nExample: Run every 30 minutes\n");
1283    out.push_str("  $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 30) -Once -At (Get-Date)\n");
1284    out.push_str("\nView all tasks:\n");
1285    out.push_str("  Get-ScheduledTask | Select-Object TaskName,State | Sort-Object TaskName\n");
1286    out.push_str("\nDelete a task:\n");
1287    out.push_str("  Unregister-ScheduledTask -TaskName 'MyDailyTask' -Confirm:$false\n");
1288    out.push_str("\nRun a task immediately:\n");
1289    out.push_str("  Start-ScheduledTask -TaskName 'MyDailyTask'\n");
1290    out.push_str("\nVerification:\n");
1291    out.push_str("  Get-ScheduledTask -TaskName 'MyDailyTask' | Select-Object TaskName,State,LastRunTime,NextRunTime\n");
1292    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.");
1293    Ok(out.trim_end().to_string())
1294}
1295
1296fn inspect_disk_cleanup_fix_plan(issue: &str) -> Result<String, String> {
1297    #[cfg(target_os = "windows")]
1298    let disk_info = {
1299        Command::new("powershell")
1300            .args([
1301                "-NoProfile",
1302                "-NonInteractive",
1303                "-Command",
1304                "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\" }",
1305            ])
1306            .output()
1307            .ok()
1308            .and_then(|o| String::from_utf8(o.stdout).ok())
1309            .unwrap_or_default()
1310            .trim()
1311            .to_string()
1312    };
1313    #[cfg(not(target_os = "windows"))]
1314    let disk_info = String::new();
1315
1316    let mut out = String::from("Host inspection: fix_plan\n\n");
1317    out.push_str(&format!("- Requested issue: {}\n", issue));
1318    out.push_str("- Fix-plan type: disk_cleanup\n");
1319    if !disk_info.is_empty() {
1320        out.push_str(&format!("\nCurrent drive usage:\n{}\n", disk_info));
1321    }
1322    out.push_str("\nFix plan — Reclaiming disk space (ordered by impact):\n");
1323    out.push_str("\n1. Run Windows Disk Cleanup (built-in, GUI):\n");
1324    out.push_str("   cleanmgr /sageset:1    (configure what to clean)\n");
1325    out.push_str("   cleanmgr /sagerun:1    (run the cleanup)\n");
1326    out.push_str("   Tick 'Windows Update Cleanup' for the biggest reclaim (often 5-20 GB).\n");
1327    out.push_str("\n2. Clear the Windows Update cache (PowerShell, Admin):\n");
1328    out.push_str("   Stop-Service wuauserv\n");
1329    out.push_str("   Remove-Item C:\\Windows\\SoftwareDistribution\\Download\\* -Recurse -Force\n");
1330    out.push_str("   Start-Service wuauserv\n");
1331    out.push_str("\n3. Clear Windows Temp folder:\n");
1332    out.push_str("   Remove-Item $env:TEMP\\* -Recurse -Force -ErrorAction SilentlyContinue\n");
1333    out.push_str(
1334        "   Remove-Item C:\\Windows\\Temp\\* -Recurse -Force -ErrorAction SilentlyContinue\n",
1335    );
1336    out.push_str("\n4. Developer cache directories (often the biggest culprits):\n");
1337    out.push_str("   - Rust build artifacts: cargo clean  (inside each project)\n");
1338    out.push_str("   - npm cache:  npm cache clean --force\n");
1339    out.push_str("   - pip cache:  pip cache purge\n");
1340    out.push_str(
1341        "   - Docker:     docker system prune -a  (removes all unused images/containers)\n",
1342    );
1343    out.push_str("   - Cargo registry cache: Remove-Item ~\\.cargo\\registry -Recurse -Force  (will redownload on next build)\n");
1344    out.push_str("\n5. Check for large files:\n");
1345    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");
1346    out.push_str("\nVerification:\n");
1347    out.push_str(
1348        "  Get-PSDrive C | Select-Object @{N='Free_GB';E={[Math]::Round($_.Free/1GB,1)}}\n",
1349    );
1350    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.");
1351    Ok(out.trim_end().to_string())
1352}
1353
1354fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
1355    let mut out = String::from("Host inspection: fix_plan\n\n");
1356    out.push_str(&format!("- Requested issue: {}\n", issue));
1357    out.push_str("- Fix-plan type: generic\n");
1358    out.push_str(
1359        "\nGuidance:\n- Use `fix_plan` with a descriptive issue string to get a grounded, machine-specific walkthrough.\n\
1360         Structured lanes available:\n\
1361         - PATH/toolchain drift (cargo, rustc, node, python, winget, choco, scoop)\n\
1362         - Port conflict (address already in use, what owns port)\n\
1363         - LM Studio connectivity (localhost:1234, no coding model loaded, embedding model)\n\
1364         - Driver install (GPU driver, nvidia driver, install driver, update driver)\n\
1365         - Group Policy (gpedit, local policy, administrative template)\n\
1366         - Firewall rule (inbound rule, outbound rule, open port, allow port, block port)\n\
1367         - SSH key (ssh-keygen, generate ssh, authorized_keys)\n\
1368         - WSL setup (wsl2, windows subsystem for linux, install ubuntu)\n\
1369         - Service config (start/stop/restart/enable/disable a service)\n\
1370         - Windows activation (product key, not activated, kms)\n\
1371         - Registry edit (regedit, reg add, hklm, hkcu, registry key)\n\
1372         - Scheduled task (task scheduler, schtasks, run on startup, cron)\n\
1373         - Disk cleanup (free up disk, clear cache, disk full, reclaim space)\n\
1374         - If your issue is outside these lanes, run the closest `inspect_host` topic first to ground the diagnosis.",
1375    );
1376    Ok(out.trim_end().to_string())
1377}
1378
1379fn inspect_resource_load() -> Result<String, String> {
1380    #[cfg(target_os = "windows")]
1381    {
1382        let output = Command::new("powershell")
1383            .args([
1384                "-NoProfile",
1385                "-Command",
1386                "(Get-CimInstance Win32_Processor).LoadPercentage; Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json -Compress",
1387            ])
1388            .output()
1389            .map_err(|e| format!("Failed to run powershell: {e}"))?;
1390
1391        let text = String::from_utf8_lossy(&output.stdout);
1392        let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
1393
1394        let cpu_load = lines
1395            .next()
1396            .and_then(|l| l.parse::<u32>().ok())
1397            .unwrap_or(0);
1398        let mem_json = lines.collect::<Vec<_>>().join("");
1399        let mem_val: Value = serde_json::from_str(&mem_json).unwrap_or(Value::Null);
1400
1401        let total_kb = mem_val["TotalVisibleMemorySize"].as_u64().unwrap_or(1);
1402        let free_kb = mem_val["FreePhysicalMemory"].as_u64().unwrap_or(0);
1403        let used_kb = total_kb.saturating_sub(free_kb);
1404        let mem_percent = if total_kb > 0 {
1405            (used_kb * 100) / total_kb
1406        } else {
1407            0
1408        };
1409
1410        let mut out = String::from("Host inspection: resource_load\n\n");
1411        out.push_str("**System Performance Summary:**\n");
1412        out.push_str(&format!("- CPU Load: {}%\n", cpu_load));
1413        out.push_str(&format!(
1414            "- Memory Usage: {} / {} ({}%)\n",
1415            human_bytes(used_kb * 1024),
1416            human_bytes(total_kb * 1024),
1417            mem_percent
1418        ));
1419
1420        if cpu_load > 85 {
1421            out.push_str("\n[Warning] CPU load is extremely high. System may be unresponsive.\n");
1422        }
1423        if mem_percent > 90 {
1424            out.push_str("\n[Warning] Memory usage is near capacity. Swap activity may slow down the machine.\n");
1425        }
1426
1427        Ok(out)
1428    }
1429    #[cfg(not(target_os = "windows"))]
1430    {
1431        Ok("Resource load inspection is not yet implemented for this platform.".to_string())
1432    }
1433}
1434
1435#[derive(Debug)]
1436enum EndpointProbe {
1437    Reachable(u16),
1438    Unreachable(String),
1439}
1440
1441async fn probe_http_endpoint(url: &str) -> EndpointProbe {
1442    let client = match reqwest::Client::builder()
1443        .timeout(std::time::Duration::from_secs(3))
1444        .build()
1445    {
1446        Ok(client) => client,
1447        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1448    };
1449
1450    match client.get(url).send().await {
1451        Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
1452        Err(err) => return EndpointProbe::Unreachable(err.to_string()),
1453    }
1454}
1455
1456async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
1457    let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
1458    let url = format!("{}/api/v0/models", base);
1459    let client = reqwest::Client::builder()
1460        .timeout(std::time::Duration::from_secs(3))
1461        .build()
1462        .ok()?;
1463
1464    #[derive(serde::Deserialize)]
1465    struct ModelList {
1466        data: Vec<ModelEntry>,
1467    }
1468    #[derive(serde::Deserialize)]
1469    struct ModelEntry {
1470        id: String,
1471        #[serde(rename = "type", default)]
1472        model_type: String,
1473        #[serde(default)]
1474        state: String,
1475    }
1476
1477    let response = client.get(url).send().await.ok()?;
1478    let models = response.json::<ModelList>().await.ok()?;
1479    models
1480        .data
1481        .into_iter()
1482        .find(|model| model.model_type == "embeddings" && model.state == "loaded")
1483        .map(|model| model.id)
1484}
1485
1486fn first_port_in_text(text: &str) -> Option<u16> {
1487    text.split(|c: char| !c.is_ascii_digit())
1488        .find(|fragment| !fragment.is_empty())
1489        .and_then(|fragment| fragment.parse::<u16>().ok())
1490}
1491
1492fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1493    let mut processes = collect_processes()?;
1494    if let Some(filter) = name_filter.as_deref() {
1495        let lowered = filter.to_ascii_lowercase();
1496        processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
1497    }
1498    processes.sort_by(|a, b| {
1499        let a_cpu = a.cpu_percent.unwrap_or(0.0);
1500        let b_cpu = b.cpu_percent.unwrap_or(0.0);
1501        b_cpu
1502            .partial_cmp(&a_cpu)
1503            .unwrap_or(std::cmp::Ordering::Equal)
1504            .then_with(|| b.memory_bytes.cmp(&a.memory_bytes))
1505            .then_with(|| a.name.cmp(&b.name))
1506            .then_with(|| a.pid.cmp(&b.pid))
1507    });
1508
1509    let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
1510
1511    let mut out = String::from("Host inspection: processes\n\n");
1512    if let Some(filter) = name_filter.as_deref() {
1513        out.push_str(&format!("- Filter name: {}\n", filter));
1514    }
1515    out.push_str(&format!("- Processes found: {}\n", processes.len()));
1516    out.push_str(&format!(
1517        "- Total reported working set: {}\n",
1518        human_bytes(total_memory)
1519    ));
1520
1521    if processes.is_empty() {
1522        out.push_str("\nNo running processes matched.");
1523        return Ok(out);
1524    }
1525
1526    out.push_str("\nTop processes by resource usage:\n");
1527    for entry in processes.iter().take(max_entries) {
1528        let cpu_str = entry
1529            .cpu_percent
1530            .map(|p| format!(" [CPU: {:.1}%]", p))
1531            .or_else(|| {
1532                entry
1533                    .cpu_seconds
1534                    .map(|s| format!(" [CPU: {:.1}s]", s))
1535            })
1536            .unwrap_or_default();
1537        let io_str = if let (Some(r), Some(w)) = (entry.read_ops, entry.write_ops) {
1538            format!(" [I/O R:{}/W:{}]", r, w)
1539        } else {
1540            " [I/O unknown]".to_string()
1541        };
1542        out.push_str(&format!(
1543            "- {} (pid {}) - {}{}{}{}\n",
1544            entry.name,
1545            entry.pid,
1546            human_bytes(entry.memory_bytes),
1547            cpu_str,
1548            io_str,
1549            entry
1550                .detail
1551                .as_deref()
1552                .map(|detail| format!(" [{}]", detail))
1553                .unwrap_or_default()
1554        ));
1555    }
1556    if processes.len() > max_entries {
1557        out.push_str(&format!(
1558            "- ... {} more processes omitted\n",
1559            processes.len() - max_entries
1560        ));
1561    }
1562
1563    Ok(out.trim_end().to_string())
1564}
1565
1566fn inspect_network(max_entries: usize) -> Result<String, String> {
1567    let adapters = collect_network_adapters()?;
1568    let active_count = adapters
1569        .iter()
1570        .filter(|adapter| adapter.is_active())
1571        .count();
1572    let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
1573
1574    let mut out = String::from("Host inspection: network\n\n");
1575    out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
1576    out.push_str(&format!("- Active adapters: {}\n", active_count));
1577    out.push_str(&format!(
1578        "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
1579        exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
1580    ));
1581
1582    if adapters.is_empty() {
1583        out.push_str("\nNo adapter details were detected.");
1584        return Ok(out);
1585    }
1586
1587    out.push_str("\nAdapter summary:\n");
1588    for adapter in adapters.iter().take(max_entries) {
1589        let status = if adapter.is_active() {
1590            "active"
1591        } else if adapter.disconnected {
1592            "disconnected"
1593        } else {
1594            "idle"
1595        };
1596        let mut details = vec![status.to_string()];
1597        if !adapter.ipv4.is_empty() {
1598            details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
1599        }
1600        if !adapter.ipv6.is_empty() {
1601            details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
1602        }
1603        if !adapter.gateways.is_empty() {
1604            details.push(format!("gateway {}", adapter.gateways.join(", ")));
1605        }
1606        if !adapter.dns_servers.is_empty() {
1607            details.push(format!("dns {}", adapter.dns_servers.join(", ")));
1608        }
1609        out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
1610    }
1611    if adapters.len() > max_entries {
1612        out.push_str(&format!(
1613            "- ... {} more adapters omitted\n",
1614            adapters.len() - max_entries
1615        ));
1616    }
1617
1618    Ok(out.trim_end().to_string())
1619}
1620
1621fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
1622    let mut services = collect_services()?;
1623    if let Some(filter) = name_filter.as_deref() {
1624        let lowered = filter.to_ascii_lowercase();
1625        services.retain(|entry| {
1626            entry.name.to_ascii_lowercase().contains(&lowered)
1627                || entry
1628                    .display_name
1629                    .as_deref()
1630                    .map(|d| d.to_ascii_lowercase().contains(&lowered))
1631                    .unwrap_or(false)
1632        });
1633    }
1634
1635    services.sort_by(|a, b| {
1636        let a_running = a.status.to_ascii_lowercase() == "running" || a.status.to_ascii_lowercase() == "active";
1637        let b_running = b.status.to_ascii_lowercase() == "running" || b.status.to_ascii_lowercase() == "active";
1638        b_running.cmp(&a_running).then_with(|| a.name.cmp(&b.name))
1639    });
1640
1641    let running = services
1642        .iter()
1643        .filter(|entry| {
1644            entry.status.eq_ignore_ascii_case("running")
1645                || entry.status.eq_ignore_ascii_case("active")
1646        })
1647        .count();
1648    let failed = services
1649        .iter()
1650        .filter(|entry| {
1651            entry.status.eq_ignore_ascii_case("failed")
1652                || entry.status.eq_ignore_ascii_case("error")
1653                || entry.status.eq_ignore_ascii_case("stopped")
1654        })
1655        .count();
1656
1657    let mut out = String::from("Host inspection: services\n\n");
1658    if let Some(filter) = name_filter.as_deref() {
1659        out.push_str(&format!("- Filter name: {}\n", filter));
1660    }
1661    out.push_str(&format!("- Services found: {}\n", services.len()));
1662    out.push_str(&format!("- Running/active: {}\n", running));
1663    out.push_str(&format!("- Failed/stopped: {}\n", failed));
1664
1665    if services.is_empty() {
1666        out.push_str("\nNo services matched.");
1667        return Ok(out);
1668    }
1669
1670    // Split into running and stopped sections so both are always visible.
1671    let per_section = (max_entries / 2).max(5);
1672
1673    let running_services: Vec<_> = services
1674        .iter()
1675        .filter(|e| {
1676            e.status.eq_ignore_ascii_case("running") || e.status.eq_ignore_ascii_case("active")
1677        })
1678        .collect();
1679    let stopped_services: Vec<_> = services
1680        .iter()
1681        .filter(|e| {
1682            e.status.eq_ignore_ascii_case("stopped")
1683                || e.status.eq_ignore_ascii_case("failed")
1684                || e.status.eq_ignore_ascii_case("error")
1685        })
1686        .collect();
1687
1688    let fmt_entry = |entry: &&ServiceEntry| {
1689        let startup = entry
1690            .startup
1691            .as_deref()
1692            .map(|v| format!(" | startup {}", v))
1693            .unwrap_or_default();
1694        let logon = entry
1695            .start_name
1696            .as_deref()
1697            .map(|v| format!(" | LogOn: {}", v))
1698            .unwrap_or_default();
1699        let display = entry
1700            .display_name
1701            .as_deref()
1702            .filter(|v| *v != &entry.name)
1703            .map(|v| format!(" [{}]", v))
1704            .unwrap_or_default();
1705        format!("- {}{} - {}{}{}\n", entry.name, display, entry.status, startup, logon)
1706    };
1707
1708    out.push_str(&format!(
1709        "\nRunning services ({} total, showing up to {}):\n",
1710        running_services.len(),
1711        per_section
1712    ));
1713    for entry in running_services.iter().take(per_section) {
1714        out.push_str(&fmt_entry(entry));
1715    }
1716    if running_services.len() > per_section {
1717        out.push_str(&format!(
1718            "- ... {} more running services omitted\n",
1719            running_services.len() - per_section
1720        ));
1721    }
1722
1723    out.push_str(&format!(
1724        "\nStopped/failed services ({} total, showing up to {}):\n",
1725        stopped_services.len(),
1726        per_section
1727    ));
1728    for entry in stopped_services.iter().take(per_section) {
1729        out.push_str(&fmt_entry(entry));
1730    }
1731    if stopped_services.len() > per_section {
1732        out.push_str(&format!(
1733            "- ... {} more stopped services omitted\n",
1734            stopped_services.len() - per_section
1735        ));
1736    }
1737
1738    Ok(out.trim_end().to_string())
1739}
1740
1741async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
1742    inspect_directory("Disk", path, max_entries).await
1743}
1744
1745fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
1746    let mut listeners = collect_listening_ports()?;
1747    if let Some(port) = port_filter {
1748        listeners.retain(|entry| entry.port == port);
1749    }
1750    listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
1751
1752    let mut out = String::from("Host inspection: ports\n\n");
1753    if let Some(port) = port_filter {
1754        out.push_str(&format!("- Filter port: {}\n", port));
1755    }
1756    out.push_str(&format!(
1757        "- Listening endpoints found: {}\n",
1758        listeners.len()
1759    ));
1760
1761    if listeners.is_empty() {
1762        out.push_str("\nNo listening endpoints matched.");
1763        return Ok(out);
1764    }
1765
1766    out.push_str("\nListening endpoints:\n");
1767    for entry in listeners.iter().take(max_entries) {
1768        let pid_str = entry
1769            .pid
1770            .as_deref()
1771            .map(|p| format!(" pid {}", p))
1772            .unwrap_or_default();
1773        let name_str = entry
1774            .process_name
1775            .as_deref()
1776            .map(|n| format!(" [{}]", n))
1777            .unwrap_or_default();
1778        out.push_str(&format!(
1779            "- {} {} ({}){}{}\n",
1780            entry.protocol, entry.local, entry.state, pid_str, name_str
1781        ));
1782    }
1783    if listeners.len() > max_entries {
1784        out.push_str(&format!(
1785            "- ... {} more listening endpoints omitted\n",
1786            listeners.len() - max_entries
1787        ));
1788    }
1789
1790    Ok(out.trim_end().to_string())
1791}
1792
1793fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
1794    if !path.exists() {
1795        return Err(format!("Path does not exist: {}", path.display()));
1796    }
1797    if !path.is_dir() {
1798        return Err(format!("Path is not a directory: {}", path.display()));
1799    }
1800
1801    let markers = collect_project_markers(&path);
1802    let hematite_state = collect_hematite_state(&path);
1803    let git_state = inspect_git_state(&path);
1804    let release_state = inspect_release_artifacts(&path);
1805
1806    let mut out = String::from("Host inspection: repo_doctor\n\n");
1807    out.push_str(&format!("- Path: {}\n", path.display()));
1808    out.push_str(&format!(
1809        "- Workspace mode: {}\n",
1810        workspace_mode_for_path(&path)
1811    ));
1812
1813    if markers.is_empty() {
1814        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");
1815    } else {
1816        out.push_str("- Project markers:\n");
1817        for marker in markers.iter().take(max_entries) {
1818            out.push_str(&format!("  - {}\n", marker));
1819        }
1820    }
1821
1822    match git_state {
1823        Some(git) => {
1824            out.push_str(&format!("- Git root: {}\n", git.root.display()));
1825            out.push_str(&format!("- Git branch: {}\n", git.branch));
1826            out.push_str(&format!("- Git status: {}\n", git.status_label()));
1827        }
1828        None => out.push_str("- Git: not inside a detected work tree\n"),
1829    }
1830
1831    out.push_str(&format!(
1832        "- Hematite docs/imports/reports: {}/{}/{}\n",
1833        hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
1834    ));
1835    if hematite_state.workspace_profile {
1836        out.push_str("- Workspace profile: present\n");
1837    } else {
1838        out.push_str("- Workspace profile: absent\n");
1839    }
1840
1841    if let Some(release) = release_state {
1842        out.push_str(&format!("- Cargo version: {}\n", release.version));
1843        out.push_str(&format!(
1844            "- Windows artifacts for current version: {}/{}/{}\n",
1845            bool_label(release.portable_dir),
1846            bool_label(release.portable_zip),
1847            bool_label(release.setup_exe)
1848        ));
1849    }
1850
1851    Ok(out.trim_end().to_string())
1852}
1853
1854async fn inspect_known_directory(
1855    label: &str,
1856    path: Option<PathBuf>,
1857    max_entries: usize,
1858) -> Result<String, String> {
1859    let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
1860    inspect_directory(label, path, max_entries).await
1861}
1862
1863async fn inspect_directory(
1864    label: &str,
1865    path: PathBuf,
1866    max_entries: usize,
1867) -> Result<String, String> {
1868    let label = label.to_string();
1869    tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
1870        .await
1871        .map_err(|e| format!("inspect_host task failed: {e}"))?
1872}
1873
1874fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
1875    if !path.exists() {
1876        return Err(format!("Path does not exist: {}", path.display()));
1877    }
1878    if !path.is_dir() {
1879        return Err(format!("Path is not a directory: {}", path.display()));
1880    }
1881
1882    let mut top_level_entries = Vec::new();
1883    for entry in fs::read_dir(path)
1884        .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
1885    {
1886        match entry {
1887            Ok(entry) => top_level_entries.push(entry),
1888            Err(_) => continue,
1889        }
1890    }
1891    top_level_entries.sort_by_key(|entry| entry.file_name());
1892
1893    let top_level_count = top_level_entries.len();
1894    let mut sample_names = Vec::new();
1895    let mut largest_entries = Vec::new();
1896    let mut aggregate = PathAggregate::default();
1897    let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
1898
1899    for entry in top_level_entries {
1900        let name = entry.file_name().to_string_lossy().to_string();
1901        if sample_names.len() < max_entries {
1902            sample_names.push(name.clone());
1903        }
1904        let kind = match entry.file_type() {
1905            Ok(ft) if ft.is_dir() => "dir",
1906            Ok(ft) if ft.is_symlink() => "symlink",
1907            _ => "file",
1908        };
1909        let stats = measure_path(&entry.path(), &mut budget);
1910        aggregate.merge(&stats);
1911        largest_entries.push(LargestEntry {
1912            name,
1913            kind,
1914            bytes: stats.total_bytes,
1915        });
1916    }
1917
1918    largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
1919
1920    let mut out = format!("Directory inspection: {}\n\n", label);
1921    out.push_str(&format!("- Path: {}\n", path.display()));
1922    out.push_str(&format!("- Top-level items: {}\n", top_level_count));
1923    out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
1924    out.push_str(&format!(
1925        "- Recursive directories: {}\n",
1926        aggregate.dir_count
1927    ));
1928    out.push_str(&format!(
1929        "- Total size: {}{}\n",
1930        human_bytes(aggregate.total_bytes),
1931        if aggregate.partial {
1932            " (partial scan)"
1933        } else {
1934            ""
1935        }
1936    ));
1937    if aggregate.skipped_entries > 0 {
1938        out.push_str(&format!(
1939            "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
1940            aggregate.skipped_entries
1941        ));
1942    }
1943
1944    if !largest_entries.is_empty() {
1945        out.push_str("\nLargest top-level entries:\n");
1946        for entry in largest_entries.iter().take(max_entries) {
1947            out.push_str(&format!(
1948                "- {} [{}] - {}\n",
1949                entry.name,
1950                entry.kind,
1951                human_bytes(entry.bytes)
1952            ));
1953        }
1954    }
1955
1956    if !sample_names.is_empty() {
1957        out.push_str("\nSample names:\n");
1958        for name in sample_names {
1959            out.push_str(&format!("- {}\n", name));
1960        }
1961    }
1962
1963    Ok(out.trim_end().to_string())
1964}
1965
1966fn resolve_path(raw: &str) -> Result<PathBuf, String> {
1967    let trimmed = raw.trim();
1968    if trimmed.is_empty() {
1969        return Err("Path must not be empty.".to_string());
1970    }
1971
1972    if let Some(rest) = trimmed
1973        .strip_prefix("~/")
1974        .or_else(|| trimmed.strip_prefix("~\\"))
1975    {
1976        let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
1977        return Ok(home.join(rest));
1978    }
1979
1980    let path = PathBuf::from(trimmed);
1981    if path.is_absolute() {
1982        Ok(path)
1983    } else {
1984        let cwd =
1985            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
1986        let full_path = cwd.join(&path);
1987
1988        // Heuristic: If it's a relative path to .hematite or hematite.exe and doesn't exist here,
1989        // check the user's home directory.
1990        if !full_path.exists()
1991            && (trimmed.starts_with(".hematite") || trimmed.starts_with("hematite.exe"))
1992        {
1993            if let Some(home) = home::home_dir() {
1994                let home_path = home.join(trimmed);
1995                if home_path.exists() {
1996                    return Ok(home_path);
1997                }
1998            }
1999        }
2000
2001        Ok(full_path)
2002    }
2003}
2004
2005fn workspace_mode_label(workspace_root: &Path) -> &'static str {
2006    workspace_mode_for_path(workspace_root)
2007}
2008
2009fn workspace_mode_for_path(path: &Path) -> &'static str {
2010    if is_project_marker_path(path) {
2011        "project"
2012    } else if path.join(".hematite").join("docs").exists()
2013        || path.join(".hematite").join("imports").exists()
2014        || path.join(".hematite").join("reports").exists()
2015    {
2016        "docs-only"
2017    } else {
2018        "general directory"
2019    }
2020}
2021
2022fn is_project_marker_path(path: &Path) -> bool {
2023    [
2024        "Cargo.toml",
2025        "package.json",
2026        "pyproject.toml",
2027        "go.mod",
2028        "composer.json",
2029        "requirements.txt",
2030        "Makefile",
2031        "justfile",
2032    ]
2033    .iter()
2034    .any(|name| path.join(name).exists())
2035        || path.join(".git").exists()
2036}
2037
2038fn preferred_shell_label() -> &'static str {
2039    #[cfg(target_os = "windows")]
2040    {
2041        "PowerShell"
2042    }
2043    #[cfg(not(target_os = "windows"))]
2044    {
2045        "sh"
2046    }
2047}
2048
2049fn desktop_dir() -> Option<PathBuf> {
2050    home::home_dir().map(|home| home.join("Desktop"))
2051}
2052
2053fn downloads_dir() -> Option<PathBuf> {
2054    home::home_dir().map(|home| home.join("Downloads"))
2055}
2056
2057fn count_top_level_items(path: &Path) -> Result<usize, String> {
2058    let mut count = 0usize;
2059    for entry in
2060        fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
2061    {
2062        if entry.is_ok() {
2063            count += 1;
2064        }
2065    }
2066    Ok(count)
2067}
2068
2069#[derive(Default)]
2070struct PathAggregate {
2071    total_bytes: u64,
2072    file_count: u64,
2073    dir_count: u64,
2074    skipped_entries: u64,
2075    partial: bool,
2076}
2077
2078impl PathAggregate {
2079    fn merge(&mut self, other: &PathAggregate) {
2080        self.total_bytes += other.total_bytes;
2081        self.file_count += other.file_count;
2082        self.dir_count += other.dir_count;
2083        self.skipped_entries += other.skipped_entries;
2084        self.partial |= other.partial;
2085    }
2086}
2087
2088struct LargestEntry {
2089    name: String,
2090    kind: &'static str,
2091    bytes: u64,
2092}
2093
2094fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
2095    if *budget == 0 {
2096        return PathAggregate {
2097            partial: true,
2098            skipped_entries: 1,
2099            ..PathAggregate::default()
2100        };
2101    }
2102    *budget -= 1;
2103
2104    let metadata = match fs::symlink_metadata(path) {
2105        Ok(metadata) => metadata,
2106        Err(_) => {
2107            return PathAggregate {
2108                skipped_entries: 1,
2109                ..PathAggregate::default()
2110            }
2111        }
2112    };
2113
2114    let file_type = metadata.file_type();
2115    if file_type.is_symlink() {
2116        return PathAggregate {
2117            skipped_entries: 1,
2118            ..PathAggregate::default()
2119        };
2120    }
2121
2122    if metadata.is_file() {
2123        return PathAggregate {
2124            total_bytes: metadata.len(),
2125            file_count: 1,
2126            ..PathAggregate::default()
2127        };
2128    }
2129
2130    if !metadata.is_dir() {
2131        return PathAggregate::default();
2132    }
2133
2134    let mut aggregate = PathAggregate {
2135        dir_count: 1,
2136        ..PathAggregate::default()
2137    };
2138
2139    let read_dir = match fs::read_dir(path) {
2140        Ok(read_dir) => read_dir,
2141        Err(_) => {
2142            aggregate.skipped_entries += 1;
2143            return aggregate;
2144        }
2145    };
2146
2147    for child in read_dir {
2148        match child {
2149            Ok(child) => {
2150                let child_stats = measure_path(&child.path(), budget);
2151                aggregate.merge(&child_stats);
2152            }
2153            Err(_) => aggregate.skipped_entries += 1,
2154        }
2155    }
2156
2157    aggregate
2158}
2159
2160struct PathAnalysis {
2161    total_entries: usize,
2162    unique_entries: usize,
2163    entries: Vec<String>,
2164    duplicate_entries: Vec<String>,
2165    missing_entries: Vec<String>,
2166}
2167
2168fn analyze_path_env() -> PathAnalysis {
2169    let mut entries = Vec::new();
2170    let mut duplicate_entries = Vec::new();
2171    let mut missing_entries = Vec::new();
2172    let mut seen = HashSet::new();
2173
2174    let raw_path = std::env::var_os("PATH").unwrap_or_default();
2175    for path in std::env::split_paths(&raw_path) {
2176        let display = path.display().to_string();
2177        if display.trim().is_empty() {
2178            continue;
2179        }
2180
2181        let normalized = normalize_path_entry(&display);
2182        if !seen.insert(normalized) {
2183            duplicate_entries.push(display.clone());
2184        }
2185        if !path.exists() {
2186            missing_entries.push(display.clone());
2187        }
2188        entries.push(display);
2189    }
2190
2191    let total_entries = entries.len();
2192    let unique_entries = seen.len();
2193
2194    PathAnalysis {
2195        total_entries,
2196        unique_entries,
2197        entries,
2198        duplicate_entries,
2199        missing_entries,
2200    }
2201}
2202
2203fn normalize_path_entry(value: &str) -> String {
2204    #[cfg(target_os = "windows")]
2205    {
2206        value
2207            .replace('/', "\\")
2208            .trim_end_matches(['\\', '/'])
2209            .to_ascii_lowercase()
2210    }
2211    #[cfg(not(target_os = "windows"))]
2212    {
2213        value.trim_end_matches('/').to_string()
2214    }
2215}
2216
2217struct ToolchainReport {
2218    found: Vec<(String, String)>,
2219    missing: Vec<String>,
2220}
2221
2222struct PackageManagerReport {
2223    found: Vec<(String, String)>,
2224}
2225
2226#[derive(Debug, Clone)]
2227struct ProcessEntry {
2228    name: String,
2229    pid: u32,
2230    memory_bytes: u64,
2231    cpu_seconds: Option<f64>,
2232    cpu_percent: Option<f64>,
2233    read_ops: Option<u64>,
2234    write_ops: Option<u64>,
2235    detail: Option<String>,
2236}
2237
2238#[derive(Debug, Clone)]
2239struct ServiceEntry {
2240    name: String,
2241    status: String,
2242    startup: Option<String>,
2243    display_name: Option<String>,
2244    start_name: Option<String>,
2245}
2246
2247#[derive(Debug, Clone, Default)]
2248struct NetworkAdapter {
2249    name: String,
2250    ipv4: Vec<String>,
2251    ipv6: Vec<String>,
2252    gateways: Vec<String>,
2253    dns_servers: Vec<String>,
2254    disconnected: bool,
2255}
2256
2257impl NetworkAdapter {
2258    fn is_active(&self) -> bool {
2259        !self.disconnected
2260            && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
2261    }
2262}
2263
2264#[derive(Debug, Clone, Copy, Default)]
2265struct ListenerExposureSummary {
2266    loopback_only: usize,
2267    wildcard_public: usize,
2268    specific_bind: usize,
2269}
2270
2271#[derive(Debug, Clone)]
2272struct ListeningPort {
2273    protocol: String,
2274    local: String,
2275    port: u16,
2276    state: String,
2277    pid: Option<String>,
2278    process_name: Option<String>,
2279}
2280
2281fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
2282    #[cfg(target_os = "windows")]
2283    {
2284        collect_windows_listening_ports()
2285    }
2286    #[cfg(not(target_os = "windows"))]
2287    {
2288        collect_unix_listening_ports()
2289    }
2290}
2291
2292fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2293    #[cfg(target_os = "windows")]
2294    {
2295        collect_windows_network_adapters()
2296    }
2297    #[cfg(not(target_os = "windows"))]
2298    {
2299        collect_unix_network_adapters()
2300    }
2301}
2302
2303fn collect_services() -> Result<Vec<ServiceEntry>, String> {
2304    #[cfg(target_os = "windows")]
2305    {
2306        collect_windows_services()
2307    }
2308    #[cfg(not(target_os = "windows"))]
2309    {
2310        collect_unix_services()
2311    }
2312}
2313
2314#[cfg(target_os = "windows")]
2315fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
2316    let output = Command::new("netstat")
2317        .args(["-ano", "-p", "tcp"])
2318        .output()
2319        .map_err(|e| format!("Failed to run netstat: {e}"))?;
2320    if !output.status.success() {
2321        return Err("netstat returned a non-success status.".to_string());
2322    }
2323
2324    let text = String::from_utf8_lossy(&output.stdout);
2325    let mut listeners = Vec::new();
2326    for line in text.lines() {
2327        let trimmed = line.trim();
2328        if !trimmed.starts_with("TCP") {
2329            continue;
2330        }
2331        let cols: Vec<&str> = trimmed.split_whitespace().collect();
2332        if cols.len() < 5 || cols[3] != "LISTENING" {
2333            continue;
2334        }
2335        let Some(port) = extract_port_from_socket(cols[1]) else {
2336            continue;
2337        };
2338        listeners.push(ListeningPort {
2339            protocol: cols[0].to_string(),
2340            local: cols[1].to_string(),
2341            port,
2342            state: cols[3].to_string(),
2343            pid: Some(cols[4].to_string()),
2344            process_name: None,
2345        });
2346    }
2347
2348    // Enrich with process names via PowerShell — works without elevation for
2349    // most user-space processes. System processes (PID 4, etc.) stay unnamed.
2350    let unique_pids: Vec<String> = listeners
2351        .iter()
2352        .filter_map(|l| l.pid.clone())
2353        .collect::<HashSet<_>>()
2354        .into_iter()
2355        .collect();
2356
2357    if !unique_pids.is_empty() {
2358        let pid_list = unique_pids.join(",");
2359        let ps_cmd = format!(
2360            "Get-Process -Id {} -ErrorAction SilentlyContinue | Select-Object Id,Name | Format-Table -HideTableHeaders",
2361            pid_list
2362        );
2363        if let Ok(ps_out) = Command::new("powershell")
2364            .args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
2365            .output()
2366        {
2367            let mut pid_map = std::collections::HashMap::<String, String>::new();
2368            let ps_text = String::from_utf8_lossy(&ps_out.stdout);
2369            for line in ps_text.lines() {
2370                let parts: Vec<&str> = line.split_whitespace().collect();
2371                if parts.len() >= 2 {
2372                    pid_map.insert(parts[0].to_string(), parts[1].to_string());
2373                }
2374            }
2375            for listener in &mut listeners {
2376                if let Some(pid) = &listener.pid {
2377                    listener.process_name = pid_map.get(pid).cloned();
2378                }
2379            }
2380        }
2381    }
2382
2383    Ok(listeners)
2384}
2385
2386#[cfg(not(target_os = "windows"))]
2387fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
2388    let output = Command::new("ss")
2389        .args(["-ltn"])
2390        .output()
2391        .map_err(|e| format!("Failed to run ss: {e}"))?;
2392    if !output.status.success() {
2393        return Err("ss returned a non-success status.".to_string());
2394    }
2395
2396    let text = String::from_utf8_lossy(&output.stdout);
2397    let mut listeners = Vec::new();
2398    for line in text.lines().skip(1) {
2399        let cols: Vec<&str> = line.split_whitespace().collect();
2400        if cols.len() < 4 {
2401            continue;
2402        }
2403        let Some(port) = extract_port_from_socket(cols[3]) else {
2404            continue;
2405        };
2406        listeners.push(ListeningPort {
2407            protocol: "tcp".to_string(),
2408            local: cols[3].to_string(),
2409            port,
2410            state: cols[0].to_string(),
2411            pid: None,
2412            process_name: None,
2413        });
2414    }
2415
2416    Ok(listeners)
2417}
2418
2419fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
2420    #[cfg(target_os = "windows")]
2421    {
2422        collect_windows_processes()
2423    }
2424    #[cfg(not(target_os = "windows"))]
2425    {
2426        collect_unix_processes()
2427    }
2428}
2429
2430#[cfg(target_os = "windows")]
2431fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
2432    let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName,StartName | ConvertTo-Json -Compress";
2433    let output = Command::new("powershell")
2434        .args(["-NoProfile", "-Command", command])
2435        .output()
2436        .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
2437    if !output.status.success() {
2438        return Err("PowerShell service inspection returned a non-success status.".to_string());
2439    }
2440
2441    parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
2442}
2443
2444#[cfg(not(target_os = "windows"))]
2445fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
2446    let status_output = Command::new("systemctl")
2447        .args([
2448            "list-units",
2449            "--type=service",
2450            "--all",
2451            "--no-pager",
2452            "--no-legend",
2453            "--plain",
2454        ])
2455        .output()
2456        .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
2457    if !status_output.status.success() {
2458        return Err("systemctl list-units returned a non-success status.".to_string());
2459    }
2460
2461    let startup_output = Command::new("systemctl")
2462        .args([
2463            "list-unit-files",
2464            "--type=service",
2465            "--no-legend",
2466            "--no-pager",
2467            "--plain",
2468        ])
2469        .output()
2470        .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
2471    if !startup_output.status.success() {
2472        return Err("systemctl list-unit-files returned a non-success status.".to_string());
2473    }
2474
2475    Ok(parse_unix_services(
2476        &String::from_utf8_lossy(&status_output.stdout),
2477        &String::from_utf8_lossy(&startup_output.stdout),
2478    ))
2479}
2480
2481#[cfg(target_os = "windows")]
2482fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2483    let output = Command::new("ipconfig")
2484        .args(["/all"])
2485        .output()
2486        .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
2487    if !output.status.success() {
2488        return Err("ipconfig returned a non-success status.".to_string());
2489    }
2490
2491    Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
2492        &output.stdout,
2493    )))
2494}
2495
2496#[cfg(not(target_os = "windows"))]
2497fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
2498    let addr_output = Command::new("ip")
2499        .args(["-o", "addr", "show", "up"])
2500        .output()
2501        .map_err(|e| format!("Failed to run ip addr: {e}"))?;
2502    if !addr_output.status.success() {
2503        return Err("ip addr returned a non-success status.".to_string());
2504    }
2505
2506    let route_output = Command::new("ip")
2507        .args(["route", "show", "default"])
2508        .output()
2509        .map_err(|e| format!("Failed to run ip route: {e}"))?;
2510    if !route_output.status.success() {
2511        return Err("ip route returned a non-success status.".to_string());
2512    }
2513
2514    let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
2515    apply_unix_default_routes(
2516        &mut adapters,
2517        &String::from_utf8_lossy(&route_output.stdout),
2518    );
2519    apply_unix_dns_servers(&mut adapters);
2520    Ok(adapters)
2521}
2522
2523#[cfg(target_os = "windows")]
2524fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
2525    // We take two samples of CPU time separated by a short interval to calculate recent CPU %
2526    let script = r#"
2527        $s1 = Get-Process | Select-Object Id, CPU
2528        Start-Sleep -Milliseconds 250
2529        $s2 = Get-Process | Select-Object Name, Id, WorkingSet64, CPU, ReadOperationCount, WriteOperationCount
2530        $s2 | ForEach-Object {
2531            $p2 = $_
2532            $p1 = $s1 | Where-Object { $_.Id -eq $p2.Id }
2533            $pct = 0.0
2534            if ($p1 -and $p2.CPU -gt $p1.CPU) {
2535                # (Delta CPU seconds / interval) * 100 / LogicalProcessors
2536                # Note: We skip division by logical processors to show 'per-core' usage or just raw % if preferred.
2537                # Standard Task Manager style is (delta / interval) * 100.
2538                $pct = [math]::Round((($p2.CPU - $p1.CPU) / 0.25) * 100, 1)
2539            }
2540            "PID:$($p2.Id)|NAME:$($p2.Name)|MEM:$($p2.WorkingSet64)|CPU_S:$($p2.CPU)|CPU_P:$pct|READ:$($p2.ReadOperationCount)|WRITE:$($p2.WriteOperationCount)"
2541        }
2542    "#;
2543
2544    let output = Command::new("powershell")
2545        .args(["-NoProfile", "-Command", script])
2546        .output()
2547        .map_err(|e| format!("Failed to run powershell Get-Process: {e}"))?;
2548
2549    let text = String::from_utf8_lossy(&output.stdout);
2550    let mut out = Vec::new();
2551    for line in text.lines() {
2552        let parts: Vec<&str> = line.trim().split('|').collect();
2553        if parts.len() < 5 {
2554            continue;
2555        }
2556        let mut entry = ProcessEntry {
2557            name: "unknown".to_string(),
2558            pid: 0,
2559            memory_bytes: 0,
2560            cpu_seconds: None,
2561            cpu_percent: None,
2562            read_ops: None,
2563            write_ops: None,
2564            detail: None,
2565        };
2566        for p in parts {
2567            if let Some((k, v)) = p.split_once(':') {
2568                match k {
2569                    "PID" => entry.pid = v.parse().unwrap_or(0),
2570                    "NAME" => entry.name = v.to_string(),
2571                    "MEM" => entry.memory_bytes = v.parse().unwrap_or(0),
2572                    "CPU_S" => entry.cpu_seconds = v.parse().ok(),
2573                    "CPU_P" => entry.cpu_percent = v.parse().ok(),
2574                    "READ" => entry.read_ops = v.parse().ok(),
2575                    "WRITE" => entry.write_ops = v.parse().ok(),
2576                    _ => {}
2577                }
2578            }
2579        }
2580        out.push(entry);
2581    }
2582    Ok(out)
2583}
2584
2585#[cfg(not(target_os = "windows"))]
2586fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
2587    let output = Command::new("ps")
2588        .args(["-eo", "pid=,rss=,comm="])
2589        .output()
2590        .map_err(|e| format!("Failed to run ps: {e}"))?;
2591    if !output.status.success() {
2592        return Err("ps returned a non-success status.".to_string());
2593    }
2594
2595    let text = String::from_utf8_lossy(&output.stdout);
2596    let mut processes = Vec::new();
2597    for line in text.lines() {
2598        let cols: Vec<&str> = line.split_whitespace().collect();
2599        if cols.len() < 3 {
2600            continue;
2601        }
2602        let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
2603        else {
2604            continue;
2605        };
2606        processes.push(ProcessEntry {
2607            name: cols[2..].join(" "),
2608            pid,
2609            memory_bytes: rss_kib * 1024,
2610            cpu_seconds: None,
2611            cpu_percent: None,
2612            read_ops: None,
2613            write_ops: None,
2614            detail: None,
2615        });
2616    }
2617
2618    Ok(processes)
2619}
2620
2621fn extract_port_from_socket(value: &str) -> Option<u16> {
2622    let cleaned = value.trim().trim_matches(['[', ']']);
2623    let port_str = cleaned.rsplit(':').next()?;
2624    port_str.parse::<u16>().ok()
2625}
2626
2627fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
2628    let mut summary = ListenerExposureSummary::default();
2629    for entry in listeners {
2630        let local = entry.local.to_ascii_lowercase();
2631        if is_loopback_listener(&local) {
2632            summary.loopback_only += 1;
2633        } else if is_wildcard_listener(&local) {
2634            summary.wildcard_public += 1;
2635        } else {
2636            summary.specific_bind += 1;
2637        }
2638    }
2639    summary
2640}
2641
2642
2643
2644fn is_loopback_listener(local: &str) -> bool {
2645    local.starts_with("127.")
2646        || local.starts_with("[::1]")
2647        || local.starts_with("::1")
2648        || local.starts_with("localhost:")
2649}
2650
2651fn is_wildcard_listener(local: &str) -> bool {
2652    local.starts_with("0.0.0.0:")
2653        || local.starts_with("[::]:")
2654        || local.starts_with(":::")
2655        || local == "*:*"
2656}
2657
2658struct GitState {
2659    root: PathBuf,
2660    branch: String,
2661    dirty_entries: usize,
2662}
2663
2664impl GitState {
2665    fn status_label(&self) -> String {
2666        if self.dirty_entries == 0 {
2667            "clean".to_string()
2668        } else {
2669            format!("dirty ({} changed path(s))", self.dirty_entries)
2670        }
2671    }
2672}
2673
2674fn inspect_git_state(path: &Path) -> Option<GitState> {
2675    let root = capture_first_line(
2676        "git",
2677        &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
2678    )?;
2679    let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
2680        .unwrap_or_else(|| "detached".to_string());
2681    let output = Command::new("git")
2682        .args(["-C", path.to_str()?, "status", "--short"])
2683        .output()
2684        .ok()?;
2685    if !output.status.success() {
2686        return None;
2687    }
2688    let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
2689    Some(GitState {
2690        root: PathBuf::from(root),
2691        branch,
2692        dirty_entries,
2693    })
2694}
2695
2696struct HematiteState {
2697    docs_count: usize,
2698    import_count: usize,
2699    report_count: usize,
2700    workspace_profile: bool,
2701}
2702
2703fn collect_hematite_state(path: &Path) -> HematiteState {
2704    let root = path.join(".hematite");
2705    HematiteState {
2706        docs_count: count_entries_if_exists(&root.join("docs")),
2707        import_count: count_entries_if_exists(&root.join("imports")),
2708        report_count: count_entries_if_exists(&root.join("reports")),
2709        workspace_profile: root.join("workspace_profile.json").exists(),
2710    }
2711}
2712
2713fn count_entries_if_exists(path: &Path) -> usize {
2714    if !path.exists() || !path.is_dir() {
2715        return 0;
2716    }
2717    fs::read_dir(path)
2718        .ok()
2719        .map(|iter| iter.filter(|entry| entry.is_ok()).count())
2720        .unwrap_or(0)
2721}
2722
2723fn collect_project_markers(path: &Path) -> Vec<String> {
2724    [
2725        "Cargo.toml",
2726        "package.json",
2727        "pyproject.toml",
2728        "go.mod",
2729        "justfile",
2730        "Makefile",
2731        ".git",
2732    ]
2733    .iter()
2734    .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
2735    .collect()
2736}
2737
2738struct ReleaseArtifactState {
2739    version: String,
2740    portable_dir: bool,
2741    portable_zip: bool,
2742    setup_exe: bool,
2743}
2744
2745fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
2746    let cargo_toml = path.join("Cargo.toml");
2747    if !cargo_toml.exists() {
2748        return None;
2749    }
2750    let cargo_text = fs::read_to_string(cargo_toml).ok()?;
2751    let version = [regex_line_capture(
2752        &cargo_text,
2753        r#"(?m)^version\s*=\s*"([^"]+)""#,
2754    )?]
2755    .concat();
2756    let dist_windows = path.join("dist").join("windows");
2757    let prefix = format!("Hematite-{}", version);
2758    Some(ReleaseArtifactState {
2759        version,
2760        portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
2761        portable_zip: dist_windows
2762            .join(format!("{}-portable.zip", prefix))
2763            .exists(),
2764        setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
2765    })
2766}
2767
2768fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
2769    let regex = regex::Regex::new(pattern).ok()?;
2770    let captures = regex.captures(text)?;
2771    captures.get(1).map(|m| m.as_str().to_string())
2772}
2773
2774fn bool_label(value: bool) -> &'static str {
2775    if value {
2776        "yes"
2777    } else {
2778        "no"
2779    }
2780}
2781
2782fn collect_toolchains() -> ToolchainReport {
2783    let checks = [
2784        ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
2785        ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
2786        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
2787        ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
2788        ToolCheck::new(
2789            "npm",
2790            &[
2791                CommandProbe::new("npm", &["--version"]),
2792                CommandProbe::new("npm.cmd", &["--version"]),
2793            ],
2794        ),
2795        ToolCheck::new(
2796            "pnpm",
2797            &[
2798                CommandProbe::new("pnpm", &["--version"]),
2799                CommandProbe::new("pnpm.cmd", &["--version"]),
2800            ],
2801        ),
2802        ToolCheck::new(
2803            "python",
2804            &[
2805                CommandProbe::new("python", &["--version"]),
2806                CommandProbe::new("python3", &["--version"]),
2807                CommandProbe::new("py", &["-3", "--version"]),
2808                CommandProbe::new("py", &["--version"]),
2809            ],
2810        ),
2811        ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
2812        ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
2813        ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
2814        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
2815    ];
2816
2817    let mut found = Vec::new();
2818    let mut missing = Vec::new();
2819
2820    for check in checks {
2821        match check.detect() {
2822            Some(version) => found.push((check.label.to_string(), version)),
2823            None => missing.push(check.label.to_string()),
2824        }
2825    }
2826
2827    ToolchainReport { found, missing }
2828}
2829
2830fn collect_package_managers() -> PackageManagerReport {
2831    let checks = [
2832        ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
2833        ToolCheck::new(
2834            "npm",
2835            &[
2836                CommandProbe::new("npm", &["--version"]),
2837                CommandProbe::new("npm.cmd", &["--version"]),
2838            ],
2839        ),
2840        ToolCheck::new(
2841            "pnpm",
2842            &[
2843                CommandProbe::new("pnpm", &["--version"]),
2844                CommandProbe::new("pnpm.cmd", &["--version"]),
2845            ],
2846        ),
2847        ToolCheck::new(
2848            "pip",
2849            &[
2850                CommandProbe::new("python", &["-m", "pip", "--version"]),
2851                CommandProbe::new("python3", &["-m", "pip", "--version"]),
2852                CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
2853                CommandProbe::new("py", &["-m", "pip", "--version"]),
2854                CommandProbe::new("pip", &["--version"]),
2855            ],
2856        ),
2857        ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
2858        ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
2859        ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
2860        ToolCheck::new(
2861            "choco",
2862            &[
2863                CommandProbe::new("choco", &["--version"]),
2864                CommandProbe::new("choco.exe", &["--version"]),
2865            ],
2866        ),
2867        ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
2868    ];
2869
2870    let mut found = Vec::new();
2871    for check in checks {
2872        match check.detect() {
2873            Some(version) => found.push((check.label.to_string(), version)),
2874            None => {}
2875        }
2876    }
2877
2878    PackageManagerReport { found }
2879}
2880
2881#[derive(Clone)]
2882struct ToolCheck {
2883    label: &'static str,
2884    probes: Vec<CommandProbe>,
2885}
2886
2887impl ToolCheck {
2888    fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
2889        Self {
2890            label,
2891            probes: probes.to_vec(),
2892        }
2893    }
2894
2895    fn detect(&self) -> Option<String> {
2896        for probe in &self.probes {
2897            if let Some(output) = capture_first_line(probe.program, probe.args) {
2898                return Some(output);
2899            }
2900        }
2901        None
2902    }
2903}
2904
2905#[derive(Clone, Copy)]
2906struct CommandProbe {
2907    program: &'static str,
2908    args: &'static [&'static str],
2909}
2910
2911impl CommandProbe {
2912    const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
2913        Self { program, args }
2914    }
2915}
2916
2917fn build_env_doctor_findings(
2918    toolchains: &ToolchainReport,
2919    package_managers: &PackageManagerReport,
2920    path_stats: &PathAnalysis,
2921) -> Vec<String> {
2922    let found_tools = toolchains
2923        .found
2924        .iter()
2925        .map(|(label, _)| label.as_str())
2926        .collect::<HashSet<_>>();
2927    let found_managers = package_managers
2928        .found
2929        .iter()
2930        .map(|(label, _)| label.as_str())
2931        .collect::<HashSet<_>>();
2932
2933    let mut findings = Vec::new();
2934
2935    if path_stats.duplicate_entries.len() > 0 {
2936        findings.push(format!(
2937            "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
2938            path_stats.duplicate_entries.len()
2939        ));
2940    }
2941    if path_stats.missing_entries.len() > 0 {
2942        findings.push(format!(
2943            "PATH contains {} entries that do not exist on disk.",
2944            path_stats.missing_entries.len()
2945        ));
2946    }
2947    if found_tools.contains("rustc") && !found_managers.contains("cargo") {
2948        findings.push(
2949            "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
2950                .to_string(),
2951        );
2952    }
2953    if found_tools.contains("node")
2954        && !found_managers.contains("npm")
2955        && !found_managers.contains("pnpm")
2956    {
2957        findings.push(
2958            "Node is present but no JavaScript package manager was detected (npm or pnpm)."
2959                .to_string(),
2960        );
2961    }
2962    if found_tools.contains("python")
2963        && !found_managers.contains("pip")
2964        && !found_managers.contains("uv")
2965        && !found_managers.contains("pipx")
2966    {
2967        findings.push(
2968            "Python is present but no Python package manager was detected (pip, uv, or pipx)."
2969                .to_string(),
2970        );
2971    }
2972    let windows_manager_count = ["winget", "choco", "scoop"]
2973        .iter()
2974        .filter(|label| found_managers.contains(**label))
2975        .count();
2976    if windows_manager_count > 1 {
2977        findings.push(
2978            "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
2979                .to_string(),
2980        );
2981    }
2982    if findings.is_empty() && !found_managers.is_empty() {
2983        findings.push(
2984            "Core package-manager coverage looks healthy for a normal developer workstation."
2985                .to_string(),
2986        );
2987    }
2988
2989    findings
2990}
2991
2992fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
2993    let output = std::process::Command::new(program)
2994        .args(args)
2995        .output()
2996        .ok()?;
2997    if !output.status.success() {
2998        return None;
2999    }
3000
3001    let stdout = if output.stdout.is_empty() {
3002        String::from_utf8_lossy(&output.stderr).into_owned()
3003    } else {
3004        String::from_utf8_lossy(&output.stdout).into_owned()
3005    };
3006
3007    stdout
3008        .lines()
3009        .map(str::trim)
3010        .find(|line| !line.is_empty())
3011        .map(|line| line.to_string())
3012}
3013
3014fn human_bytes(bytes: u64) -> String {
3015    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
3016    let mut value = bytes as f64;
3017    let mut unit_index = 0usize;
3018
3019    while value >= 1024.0 && unit_index < UNITS.len() - 1 {
3020        value /= 1024.0;
3021        unit_index += 1;
3022    }
3023
3024    if unit_index == 0 {
3025        format!("{} {}", bytes, UNITS[unit_index])
3026    } else {
3027        format!("{value:.1} {}", UNITS[unit_index])
3028    }
3029}
3030
3031#[cfg(target_os = "windows")]
3032fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
3033    let mut adapters = Vec::new();
3034    let mut current: Option<NetworkAdapter> = None;
3035    let mut pending_dns = false;
3036
3037    for raw_line in text.lines() {
3038        let line = raw_line.trim_end();
3039        let trimmed = line.trim();
3040        if trimmed.is_empty() {
3041            pending_dns = false;
3042            continue;
3043        }
3044
3045        if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
3046            if let Some(adapter) = current.take() {
3047                adapters.push(adapter);
3048            }
3049            current = Some(NetworkAdapter {
3050                name: trimmed.trim_end_matches(':').to_string(),
3051                ..NetworkAdapter::default()
3052            });
3053            pending_dns = false;
3054            continue;
3055        }
3056
3057        let Some(adapter) = current.as_mut() else {
3058            continue;
3059        };
3060
3061        if trimmed.contains("Media State") && trimmed.contains("disconnected") {
3062            adapter.disconnected = true;
3063        }
3064
3065        if let Some(value) = value_after_colon(trimmed) {
3066            let normalized = normalize_ipconfig_value(value);
3067            if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
3068                adapter.ipv4.push(normalized);
3069                pending_dns = false;
3070            } else if trimmed.starts_with("IPv6 Address")
3071                || trimmed.starts_with("Temporary IPv6 Address")
3072                || trimmed.starts_with("Link-local IPv6 Address")
3073            {
3074                if !normalized.is_empty() {
3075                    adapter.ipv6.push(normalized);
3076                }
3077                pending_dns = false;
3078            } else if trimmed.starts_with("Default Gateway") {
3079                if !normalized.is_empty() {
3080                    adapter.gateways.push(normalized);
3081                }
3082                pending_dns = false;
3083            } else if trimmed.starts_with("DNS Servers") {
3084                if !normalized.is_empty() {
3085                    adapter.dns_servers.push(normalized);
3086                }
3087                pending_dns = true;
3088            } else {
3089                pending_dns = false;
3090            }
3091        } else if pending_dns {
3092            let normalized = normalize_ipconfig_value(trimmed);
3093            if !normalized.is_empty() {
3094                adapter.dns_servers.push(normalized);
3095            }
3096        }
3097    }
3098
3099    if let Some(adapter) = current.take() {
3100        adapters.push(adapter);
3101    }
3102
3103    for adapter in &mut adapters {
3104        dedup_vec(&mut adapter.ipv4);
3105        dedup_vec(&mut adapter.ipv6);
3106        dedup_vec(&mut adapter.gateways);
3107        dedup_vec(&mut adapter.dns_servers);
3108    }
3109
3110    adapters
3111}
3112
3113#[cfg(not(target_os = "windows"))]
3114fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
3115    let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
3116
3117    for line in text.lines() {
3118        let cols: Vec<&str> = line.split_whitespace().collect();
3119        if cols.len() < 4 {
3120            continue;
3121        }
3122        let name = cols[1].trim_end_matches(':').to_string();
3123        let family = cols[2];
3124        let addr = cols[3].split('/').next().unwrap_or("").to_string();
3125        let entry = adapters
3126            .entry(name.clone())
3127            .or_insert_with(|| NetworkAdapter {
3128                name,
3129                ..NetworkAdapter::default()
3130            });
3131        match family {
3132            "inet" if !addr.is_empty() => entry.ipv4.push(addr),
3133            "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
3134            _ => {}
3135        }
3136    }
3137
3138    adapters.into_values().collect()
3139}
3140
3141#[cfg(not(target_os = "windows"))]
3142fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
3143    for line in text.lines() {
3144        let cols: Vec<&str> = line.split_whitespace().collect();
3145        if cols.len() < 5 {
3146            continue;
3147        }
3148        let gateway = cols
3149            .windows(2)
3150            .find(|pair| pair[0] == "via")
3151            .map(|pair| pair[1].to_string());
3152        let dev = cols
3153            .windows(2)
3154            .find(|pair| pair[0] == "dev")
3155            .map(|pair| pair[1]);
3156        if let (Some(gateway), Some(dev)) = (gateway, dev) {
3157            if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
3158                adapter.gateways.push(gateway);
3159            }
3160        }
3161    }
3162
3163    for adapter in adapters {
3164        dedup_vec(&mut adapter.gateways);
3165    }
3166}
3167
3168#[cfg(not(target_os = "windows"))]
3169fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
3170    let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
3171        return;
3172    };
3173    let mut dns_servers = text
3174        .lines()
3175        .filter_map(|line| line.strip_prefix("nameserver "))
3176        .map(str::trim)
3177        .filter(|value| !value.is_empty())
3178        .map(|value| value.to_string())
3179        .collect::<Vec<_>>();
3180    dedup_vec(&mut dns_servers);
3181    if dns_servers.is_empty() {
3182        return;
3183    }
3184    for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
3185        adapter.dns_servers = dns_servers.clone();
3186    }
3187}
3188
3189#[cfg(target_os = "windows")]
3190fn value_after_colon(line: &str) -> Option<&str> {
3191    line.split_once(':').map(|(_, value)| value.trim())
3192}
3193
3194#[cfg(target_os = "windows")]
3195fn normalize_ipconfig_value(value: &str) -> String {
3196    value
3197        .trim()
3198        .trim_matches(['(', ')'])
3199        .trim_end_matches("(Preferred)")
3200        .trim()
3201        .to_string()
3202}
3203
3204fn dedup_vec(values: &mut Vec<String>) {
3205    let mut seen = HashSet::new();
3206    values.retain(|value| seen.insert(value.clone()));
3207}
3208
3209#[cfg(target_os = "windows")]
3210fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
3211    let trimmed = text.trim();
3212    if trimmed.is_empty() {
3213        return Ok(Vec::new());
3214    }
3215
3216    let value: Value = serde_json::from_str(trimmed)
3217        .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
3218    let entries = match value {
3219        Value::Array(items) => items,
3220        other => vec![other],
3221    };
3222
3223    let mut services = Vec::new();
3224    for entry in entries {
3225        let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
3226            continue;
3227        };
3228        services.push(ServiceEntry {
3229            name: name.to_string(),
3230            status: entry
3231                .get("State")
3232                .and_then(|v| v.as_str())
3233                .unwrap_or("unknown")
3234                .to_string(),
3235            startup: entry
3236                .get("StartMode")
3237                .and_then(|v| v.as_str())
3238                .map(|v| v.to_string()),
3239            display_name: entry
3240                .get("DisplayName")
3241                .and_then(|v| v.as_str())
3242                .map(|v| v.to_string()),
3243            start_name: entry
3244                .get("StartName")
3245                .and_then(|v| v.as_str())
3246                .map(|v| v.to_string()),
3247        });
3248    }
3249
3250    Ok(services)
3251}
3252
3253#[cfg(not(target_os = "windows"))]
3254fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
3255    let mut startup_modes = std::collections::HashMap::<String, String>::new();
3256    for line in startup_text.lines() {
3257        let cols: Vec<&str> = line.split_whitespace().collect();
3258        if cols.len() < 2 {
3259            continue;
3260        }
3261        startup_modes.insert(cols[0].to_string(), cols[1].to_string());
3262    }
3263
3264    let mut services = Vec::new();
3265    for line in status_text.lines() {
3266        let cols: Vec<&str> = line.split_whitespace().collect();
3267        if cols.len() < 4 {
3268            continue;
3269        }
3270        let unit = cols[0];
3271        let load = cols[1];
3272        let active = cols[2];
3273        let sub = cols[3];
3274        let description = if cols.len() > 4 {
3275            Some(cols[4..].join(" "))
3276        } else {
3277            None
3278        };
3279        services.push(ServiceEntry {
3280            name: unit.to_string(),
3281            status: format!("{}/{}", active, sub),
3282            startup: startup_modes
3283                .get(unit)
3284                .cloned()
3285                .or_else(|| Some(load.to_string())),
3286            display_name: description,
3287            start_name: None,
3288        });
3289    }
3290
3291    services
3292}
3293
3294// ── health_report ─────────────────────────────────────────────────────────────
3295
3296/// Synthesized system health report — runs multiple checks and returns a
3297/// plain-English tiered verdict suitable for both developers and non-technical
3298/// users who just want to know if their machine is okay.
3299fn inspect_health_report() -> Result<String, String> {
3300    let mut needs_fix: Vec<String> = Vec::new();
3301    let mut watch: Vec<String> = Vec::new();
3302    let mut good: Vec<String> = Vec::new();
3303    let mut tips: Vec<String> = Vec::new();
3304
3305    health_check_disk(&mut needs_fix, &mut watch, &mut good);
3306    health_check_memory(&mut watch, &mut good);
3307    health_check_tools(&mut watch, &mut good, &mut tips);
3308    health_check_recent_errors(&mut watch, &mut tips);
3309
3310    let overall = if !needs_fix.is_empty() {
3311        "ACTION REQUIRED"
3312    } else if !watch.is_empty() {
3313        "WORTH A LOOK"
3314    } else {
3315        "ALL GOOD"
3316    };
3317
3318    let mut out = format!("System Health Report — {overall}\n\n");
3319
3320    if !needs_fix.is_empty() {
3321        out.push_str("Needs fixing:\n");
3322        for item in &needs_fix {
3323            out.push_str(&format!("  [!] {item}\n"));
3324        }
3325        out.push('\n');
3326    }
3327    if !watch.is_empty() {
3328        out.push_str("Worth watching:\n");
3329        for item in &watch {
3330            out.push_str(&format!("  [-] {item}\n"));
3331        }
3332        out.push('\n');
3333    }
3334    if !good.is_empty() {
3335        out.push_str("Looking good:\n");
3336        for item in &good {
3337            out.push_str(&format!("  [+] {item}\n"));
3338        }
3339        out.push('\n');
3340    }
3341    if !tips.is_empty() {
3342        out.push_str("To dig deeper:\n");
3343        for tip in &tips {
3344            out.push_str(&format!("  {tip}\n"));
3345        }
3346    }
3347
3348    Ok(out.trim_end().to_string())
3349}
3350
3351fn health_check_disk(needs_fix: &mut Vec<String>, watch: &mut Vec<String>, good: &mut Vec<String>) {
3352    #[cfg(target_os = "windows")]
3353    {
3354        let script = r#"try {
3355    $d = Get-PSDrive C -ErrorAction Stop
3356    "$($d.Free)|$($d.Used)"
3357} catch { "ERR" }"#;
3358        if let Ok(out) = Command::new("powershell")
3359            .args(["-NoProfile", "-Command", script])
3360            .output()
3361        {
3362            let text = String::from_utf8_lossy(&out.stdout);
3363            let text = text.trim();
3364            if !text.starts_with("ERR") {
3365                let parts: Vec<&str> = text.split('|').collect();
3366                if parts.len() == 2 {
3367                    let free_bytes: u64 = parts[0].trim().parse().unwrap_or(0);
3368                    let used_bytes: u64 = parts[1].trim().parse().unwrap_or(0);
3369                    let total = free_bytes + used_bytes;
3370                    let free_gb = free_bytes / 1_073_741_824;
3371                    let pct_free = if total > 0 {
3372                        (free_bytes as f64 / total as f64 * 100.0) as u64
3373                    } else {
3374                        0
3375                    };
3376                    let msg = format!("Disk: {free_gb} GB free on C: ({pct_free}% available)");
3377                    if free_gb < 5 {
3378                        needs_fix.push(format!(
3379                            "{msg} — very low. Free up space or your system may slow down or stop working."
3380                        ));
3381                    } else if free_gb < 15 {
3382                        watch.push(format!("{msg} — getting low, consider cleaning up."));
3383                    } else {
3384                        good.push(msg);
3385                    }
3386                    return;
3387                }
3388            }
3389        }
3390        watch.push("Disk: could not read free space from C: drive.".to_string());
3391    }
3392
3393    #[cfg(not(target_os = "windows"))]
3394    {
3395        if let Ok(out) = Command::new("df").args(["-BG", "/"]).output() {
3396            let text = String::from_utf8_lossy(&out.stdout);
3397            for line in text.lines().skip(1) {
3398                let cols: Vec<&str> = line.split_whitespace().collect();
3399                if cols.len() >= 5 {
3400                    let avail_str = cols[3].trim_end_matches('G');
3401                    let use_pct = cols[4].trim_end_matches('%');
3402                    let avail_gb: u64 = avail_str.parse().unwrap_or(0);
3403                    let used_pct: u64 = use_pct.parse().unwrap_or(0);
3404                    let msg = format!("Disk: {avail_gb} GB free on / ({used_pct}% used)");
3405                    if avail_gb < 5 {
3406                        needs_fix.push(format!(
3407                            "{msg} — very low. Free up space to prevent system issues."
3408                        ));
3409                    } else if avail_gb < 15 {
3410                        watch.push(format!("{msg} — getting low."));
3411                    } else {
3412                        good.push(msg);
3413                    }
3414                    return;
3415                }
3416            }
3417        }
3418        watch.push("Disk: could not determine free space.".to_string());
3419    }
3420}
3421
3422fn health_check_memory(watch: &mut Vec<String>, good: &mut Vec<String>) {
3423    #[cfg(target_os = "windows")]
3424    {
3425        let script = r#"try {
3426    $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
3427    "$($os.FreePhysicalMemory)|$($os.TotalVisibleMemorySize)"
3428} catch { "ERR" }"#;
3429        if let Ok(out) = Command::new("powershell")
3430            .args(["-NoProfile", "-Command", script])
3431            .output()
3432        {
3433            let text = String::from_utf8_lossy(&out.stdout);
3434            let text = text.trim();
3435            if !text.starts_with("ERR") {
3436                let parts: Vec<&str> = text.split('|').collect();
3437                if parts.len() == 2 {
3438                    let free_kb: u64 = parts[0].trim().parse().unwrap_or(0);
3439                    let total_kb: u64 = parts[1].trim().parse().unwrap_or(0);
3440                    if total_kb > 0 {
3441                        let free_gb = free_kb / 1_048_576;
3442                        let total_gb = total_kb / 1_048_576;
3443                        let free_pct = free_kb * 100 / total_kb;
3444                        let msg = format!(
3445                            "RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)"
3446                        );
3447                        if free_pct < 10 {
3448                            watch.push(format!(
3449                                "{msg} — very low. Close unused apps to free up memory."
3450                            ));
3451                        } else if free_pct < 25 {
3452                            watch.push(format!("{msg} — running a bit low."));
3453                        } else {
3454                            good.push(msg);
3455                        }
3456                        return;
3457                    }
3458                }
3459            }
3460        }
3461    }
3462
3463    #[cfg(not(target_os = "windows"))]
3464    {
3465        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
3466            let mut total_kb = 0u64;
3467            let mut avail_kb = 0u64;
3468            for line in content.lines() {
3469                if line.starts_with("MemTotal:") {
3470                    total_kb = line
3471                        .split_whitespace()
3472                        .nth(1)
3473                        .and_then(|v| v.parse().ok())
3474                        .unwrap_or(0);
3475                } else if line.starts_with("MemAvailable:") {
3476                    avail_kb = line
3477                        .split_whitespace()
3478                        .nth(1)
3479                        .and_then(|v| v.parse().ok())
3480                        .unwrap_or(0);
3481                }
3482            }
3483            if total_kb > 0 {
3484                let free_gb = avail_kb / 1_048_576;
3485                let total_gb = total_kb / 1_048_576;
3486                let free_pct = avail_kb * 100 / total_kb;
3487                let msg =
3488                    format!("RAM: {free_gb} GB free of {total_gb} GB ({free_pct}% available)");
3489                if free_pct < 10 {
3490                    watch.push(format!("{msg} — very low. Close unused apps."));
3491                } else if free_pct < 25 {
3492                    watch.push(format!("{msg} — running a bit low."));
3493                } else {
3494                    good.push(msg);
3495                }
3496            }
3497        }
3498    }
3499}
3500
3501fn health_check_tools(watch: &mut Vec<String>, good: &mut Vec<String>, tips: &mut Vec<String>) {
3502    let tool_checks: &[(&str, &str, &str)] = &[
3503        ("git", "--version", "Git"),
3504        ("cargo", "--version", "Rust / Cargo"),
3505        ("node", "--version", "Node.js"),
3506        ("python", "--version", "Python"),
3507        ("python3", "--version", "Python 3"),
3508        ("npm", "--version", "npm"),
3509    ];
3510
3511    let mut found: Vec<String> = Vec::new();
3512    let mut missing: Vec<String> = Vec::new();
3513    let mut python_found = false;
3514
3515    for (cmd, arg, label) in tool_checks {
3516        if cmd.starts_with("python") && python_found {
3517            continue;
3518        }
3519        let ok = Command::new(cmd)
3520            .arg(arg)
3521            .stdout(std::process::Stdio::null())
3522            .stderr(std::process::Stdio::null())
3523            .status()
3524            .map(|s| s.success())
3525            .unwrap_or(false);
3526        if ok {
3527            found.push((*label).to_string());
3528            if cmd.starts_with("python") {
3529                python_found = true;
3530            }
3531        } else if !cmd.starts_with("python") || !python_found {
3532            missing.push((*label).to_string());
3533        }
3534    }
3535
3536    if !found.is_empty() {
3537        good.push(format!("Dev tools found: {}", found.join(", ")));
3538    }
3539    if !missing.is_empty() {
3540        watch.push(format!(
3541            "Not installed (or not on PATH): {} — only matters if you need them",
3542            missing.join(", ")
3543        ));
3544        tips.push(
3545            "Run inspect_host(topic=\"toolchains\") for exact version details on all dev tools."
3546                .to_string(),
3547        );
3548    }
3549}
3550
3551fn health_check_recent_errors(watch: &mut Vec<String>, tips: &mut Vec<String>) {
3552    #[cfg(target_os = "windows")]
3553    {
3554        let script = r#"try {
3555    $cutoff = (Get-Date).AddHours(-24)
3556    $count = (Get-WinEvent -FilterHashtable @{LogName='Application','System'; Level=1,2,3; StartTime=$cutoff} -MaxEvents 200 -ErrorAction SilentlyContinue | Measure-Object).Count
3557    $count
3558} catch { "0" }"#;
3559        if let Ok(out) = Command::new("powershell")
3560            .args(["-NoProfile", "-Command", script])
3561            .output()
3562        {
3563            let text = String::from_utf8_lossy(&out.stdout);
3564            let count: u64 = text.trim().parse().unwrap_or(0);
3565            if count > 0 {
3566                watch.push(format!(
3567                    "{count} critical/error event{} in Windows event logs in the last 24 hours.",
3568                    if count == 1 { "" } else { "s" }
3569                ));
3570                tips.push(
3571                    "Run inspect_host(topic=\"log_check\") to see the actual error messages."
3572                        .to_string(),
3573                );
3574            }
3575        }
3576    }
3577
3578    #[cfg(not(target_os = "windows"))]
3579    {
3580        if let Ok(out) = Command::new("journalctl")
3581            .args(["-p", "3", "-n", "1", "--no-pager", "--quiet"])
3582            .output()
3583        {
3584            let text = String::from_utf8_lossy(&out.stdout);
3585            if !text.trim().is_empty() {
3586                watch.push("Critical/error entries found in the system journal.".to_string());
3587                tips.push(
3588                    "Run inspect_host(topic=\"log_check\") to see recent errors.".to_string(),
3589                );
3590            }
3591        }
3592    }
3593}
3594
3595// ── log_check ─────────────────────────────────────────────────────────────────
3596
3597fn inspect_log_check(lookback_hours: Option<u32>, max_entries: usize) -> Result<String, String> {
3598    let mut out = String::from("Host inspection: log_check\n\n");
3599
3600    #[cfg(target_os = "windows")]
3601    {
3602        // Pull recent critical/error events from Windows Application and System logs.
3603        let hours = lookback_hours.unwrap_or(24);
3604        out.push_str(&format!("Checking System/Application logs from the last {} hours...\n\n", hours));
3605        
3606        let n = max_entries.clamp(1, 50);
3607        let script = format!(
3608            r#"try {{
3609    $events = Get-WinEvent -FilterHashtable @{{LogName='Application','System'; Level=1,2,3; StartTime=(Get-Date).AddHours(-{hours})}} -MaxEvents 100 -ErrorAction SilentlyContinue
3610    if (-not $events) {{ "NO_EVENTS"; exit }}
3611    $events | Select-Object -First {n} | ForEach-Object {{
3612        $line = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + '|' + $_.LevelDisplayName + '|' + $_.ProviderName + '|' + (($_.Message -split '[\r\n]')[0].Trim())
3613        $line
3614    }}
3615}} catch {{ "ERROR:" + $_.Exception.Message }}"#,
3616            hours = hours,
3617            n = n
3618        );
3619        let output = Command::new("powershell")
3620            .args(["-NoProfile", "-Command", &script])
3621            .output()
3622            .map_err(|e| format!("log_check: failed to run PowerShell: {e}"))?;
3623
3624        let raw = String::from_utf8_lossy(&output.stdout);
3625        let text = raw.trim();
3626
3627        if text.is_empty() || text == "NO_EVENTS" {
3628            out.push_str("No critical or error events found in Application/System logs.\n");
3629            return Ok(out.trim_end().to_string());
3630        }
3631        if text.starts_with("ERROR:") {
3632            out.push_str(&format!("Warning: event log query returned: {text}\n"));
3633            return Ok(out.trim_end().to_string());
3634        }
3635
3636        let mut count = 0usize;
3637        for line in text.lines() {
3638            let parts: Vec<&str> = line.splitn(4, '|').collect();
3639            if parts.len() == 4 {
3640                let (time, level, source, msg) = (parts[0], parts[1], parts[2], parts[3]);
3641                out.push_str(&format!("[{time}] [{level}] {source}: {msg}\n"));
3642                count += 1;
3643            }
3644        }
3645        out.push_str(&format!(
3646            "\nEvents shown: {count} (critical/error from Application + System logs)\n"
3647        ));
3648    }
3649
3650    #[cfg(not(target_os = "windows"))]
3651    {
3652        let _ = lookback_hours;
3653        // Use journalctl on Linux/macOS if available.
3654        let n = max_entries.clamp(1, 50).to_string();
3655        let output = Command::new("journalctl")
3656            .args(["-p", "3", "-n", &n, "--no-pager", "--output=short-precise"])
3657            .output();
3658
3659        match output {
3660            Ok(o) if o.status.success() => {
3661                let text = String::from_utf8_lossy(&o.stdout);
3662                let trimmed = text.trim();
3663                if trimmed.is_empty() || trimmed.contains("No entries") {
3664                    out.push_str("No critical or error entries found in the system journal.\n");
3665                } else {
3666                    out.push_str(trimmed);
3667                    out.push('\n');
3668                    out.push_str("\n(source: journalctl -p 3 = critical/alert/emergency/error)\n");
3669                }
3670            }
3671            _ => {
3672                // Fallback: check /var/log/syslog or /var/log/messages
3673                let log_paths = ["/var/log/syslog", "/var/log/messages"];
3674                let mut found = false;
3675                for log_path in &log_paths {
3676                    if let Ok(content) = std::fs::read_to_string(log_path) {
3677                        let lines: Vec<&str> = content.lines().collect();
3678                        let tail: Vec<&str> = lines
3679                            .iter()
3680                            .rev()
3681                            .filter(|l| {
3682                                let l_lower = l.to_ascii_lowercase();
3683                                l_lower.contains("error") || l_lower.contains("crit")
3684                            })
3685                            .take(max_entries)
3686                            .copied()
3687                            .collect::<Vec<_>>()
3688                            .into_iter()
3689                            .rev()
3690                            .collect();
3691                        if !tail.is_empty() {
3692                            out.push_str(&format!("Source: {log_path}\n"));
3693                            for l in &tail {
3694                                out.push_str(l);
3695                                out.push('\n');
3696                            }
3697                            found = true;
3698                            break;
3699                        }
3700                    }
3701                }
3702                if !found {
3703                    out.push_str(
3704                        "journalctl not found and no readable syslog detected on this system.\n",
3705                    );
3706                }
3707            }
3708        }
3709    }
3710
3711    Ok(out.trim_end().to_string())
3712}
3713
3714// ── startup_items ─────────────────────────────────────────────────────────────
3715
3716fn inspect_startup_items(max_entries: usize) -> Result<String, String> {
3717    let mut out = String::from("Host inspection: startup_items\n\n");
3718
3719    #[cfg(target_os = "windows")]
3720    {
3721        // Query both HKLM and HKCU Run keys.
3722        let script = r#"
3723$hives = @(
3724    @{Hive='HKLM'; Path='HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
3725    @{Hive='HKCU'; Path='HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'},
3726    @{Hive='HKLM (32-bit)'; Path='HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run'}
3727)
3728foreach ($h in $hives) {
3729    try {
3730        $props = Get-ItemProperty -Path $h.Path -ErrorAction Stop
3731        $props.PSObject.Properties | Where-Object { $_.Name -notlike 'PS*' } | ForEach-Object {
3732            "$($h.Hive)|$($_.Name)|$($_.Value)"
3733        }
3734    } catch {}
3735}
3736"#;
3737        let output = Command::new("powershell")
3738            .args(["-NoProfile", "-Command", script])
3739            .output()
3740            .map_err(|e| format!("startup_items: failed to run PowerShell: {e}"))?;
3741
3742        let raw = String::from_utf8_lossy(&output.stdout);
3743        let text = raw.trim();
3744
3745        let entries: Vec<(String, String, String)> = text
3746            .lines()
3747            .filter_map(|l| {
3748                let parts: Vec<&str> = l.splitn(3, '|').collect();
3749                if parts.len() == 3 {
3750                    Some((
3751                        parts[0].to_string(),
3752                        parts[1].to_string(),
3753                        parts[2].to_string(),
3754                    ))
3755                } else {
3756                    None
3757                }
3758            })
3759            .take(max_entries)
3760            .collect();
3761
3762        if entries.is_empty() {
3763            out.push_str("No startup entries found in the Windows Run registry keys.\n");
3764        } else {
3765            out.push_str("Registry run keys (programs that start with Windows):\n\n");
3766            let mut last_hive = String::new();
3767            for (hive, name, value) in &entries {
3768                if *hive != last_hive {
3769                    out.push_str(&format!("[{}]\n", hive));
3770                    last_hive = hive.clone();
3771                }
3772                // Truncate very long values (paths with many args)
3773                let display = if value.len() > 100 {
3774                    format!("{}…", &value[..100])
3775                } else {
3776                    value.clone()
3777                };
3778                out.push_str(&format!("  {name}: {display}\n"));
3779            }
3780            out.push_str(&format!("\nTotal startup entries: {}\n", entries.len()));
3781        }
3782
3783        // 3. Unified Startup Command check (Task Manager style)
3784        let unified_script = r#"Get-CimInstance Win32_StartupCommand | ForEach-Object { "  $($_.Name): $($_.Command) ($($_.Location))" }"#;
3785        if let Ok(unified_out) = Command::new("powershell")
3786            .args(["-NoProfile", "-Command", unified_script])
3787            .output()
3788        {
3789            let unified_text = String::from_utf8_lossy(&unified_out.stdout);
3790            let trimmed = unified_text.trim();
3791            if !trimmed.is_empty() {
3792                out.push_str("\n=== Unified Startup Commands (WMI) ===\n");
3793                out.push_str(trimmed);
3794                out.push('\n');
3795            }
3796        }
3797    }
3798
3799    #[cfg(not(target_os = "windows"))]
3800    {
3801        // On Linux: systemd enabled services + cron @reboot entries.
3802        let output = Command::new("systemctl")
3803            .args([
3804                "list-unit-files",
3805                "--type=service",
3806                "--state=enabled",
3807                "--no-legend",
3808                "--no-pager",
3809                "--plain",
3810            ])
3811            .output();
3812
3813        match output {
3814            Ok(o) if o.status.success() => {
3815                let text = String::from_utf8_lossy(&o.stdout);
3816                let services: Vec<&str> = text
3817                    .lines()
3818                    .filter(|l| !l.trim().is_empty())
3819                    .take(max_entries)
3820                    .collect();
3821                if services.is_empty() {
3822                    out.push_str("No enabled systemd services found.\n");
3823                } else {
3824                    out.push_str("Enabled systemd services (run at boot):\n\n");
3825                    for s in &services {
3826                        out.push_str(&format!("  {s}\n"));
3827                    }
3828                    out.push_str(&format!(
3829                        "\nShowing {} of enabled services.\n",
3830                        services.len()
3831                    ));
3832                }
3833            }
3834            _ => {
3835                out.push_str(
3836                    "systemctl not found on this system. Cannot enumerate startup services.\n",
3837                );
3838            }
3839        }
3840
3841        // Check @reboot cron entries.
3842        if let Ok(cron_out) = Command::new("crontab").args(["-l"]).output() {
3843            let cron_text = String::from_utf8_lossy(&cron_out.stdout);
3844            let reboot_entries: Vec<&str> = cron_text
3845                .lines()
3846                .filter(|l| l.trim_start().starts_with("@reboot"))
3847                .collect();
3848            if !reboot_entries.is_empty() {
3849                out.push_str("\nCron @reboot entries:\n");
3850                for e in reboot_entries {
3851                    out.push_str(&format!("  {e}\n"));
3852                }
3853            }
3854        }
3855    }
3856
3857    Ok(out.trim_end().to_string())
3858}
3859
3860fn inspect_os_config() -> Result<String, String> {
3861    let mut out = String::from("Host inspection: OS Configuration\n\n");
3862
3863    #[cfg(target_os = "windows")]
3864    {
3865        // Power Plan
3866        if let Ok(power_out) = Command::new("powercfg").args(["/getactivescheme"]).output() {
3867            let power_str = String::from_utf8_lossy(&power_out.stdout);
3868            out.push_str("=== Power Plan ===\n");
3869            out.push_str(power_str.trim());
3870            out.push_str("\n\n");
3871        }
3872
3873        // Firewall Status
3874        let fw_script =
3875            "Get-NetFirewallProfile | Format-Table -Property Name, Enabled -AutoSize | Out-String";
3876        if let Ok(fw_out) = Command::new("powershell")
3877            .args(["-NoProfile", "-Command", fw_script])
3878            .output()
3879        {
3880            let fw_str = String::from_utf8_lossy(&fw_out.stdout);
3881            out.push_str("=== Firewall Profiles ===\n");
3882            out.push_str(fw_str.trim());
3883            out.push_str("\n\n");
3884        }
3885
3886        // System Uptime
3887        let uptime_script =
3888            "(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString()";
3889        if let Ok(uptime_out) = Command::new("powershell")
3890            .args(["-NoProfile", "-Command", uptime_script])
3891            .output()
3892        {
3893            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
3894            out.push_str("=== System Uptime (Last Boot) ===\n");
3895            out.push_str(uptime_str.trim());
3896            out.push_str("\n\n");
3897        }
3898    }
3899
3900    #[cfg(not(target_os = "windows"))]
3901    {
3902        // Uptime
3903        if let Ok(uptime_out) = Command::new("uptime").args(["-p"]).output() {
3904            let uptime_str = String::from_utf8_lossy(&uptime_out.stdout);
3905            out.push_str("=== System Uptime ===\n");
3906            out.push_str(uptime_str.trim());
3907            out.push_str("\n\n");
3908        }
3909
3910        // Firewall (ufw status if available)
3911        if let Ok(ufw_out) = Command::new("ufw").arg("status").output() {
3912            let ufw_str = String::from_utf8_lossy(&ufw_out.stdout);
3913            if !ufw_str.trim().is_empty() {
3914                out.push_str("=== Firewall (UFW) ===\n");
3915                out.push_str(ufw_str.trim());
3916                out.push_str("\n\n");
3917            }
3918        }
3919    }
3920    Ok(out.trim_end().to_string())
3921}
3922
3923pub async fn resolve_host_issue(args: &Value) -> Result<String, String> {
3924    let action = args
3925        .get("action")
3926        .and_then(|v| v.as_str())
3927        .ok_or_else(|| "Missing required argument: 'action'".to_string())?;
3928
3929    let target = args
3930        .get("target")
3931        .and_then(|v| v.as_str())
3932        .unwrap_or("")
3933        .trim();
3934
3935    if target.is_empty() && action != "clear_temp" {
3936        return Err("Missing required argument: 'target' for this action".to_string());
3937    }
3938
3939    match action {
3940        "install_package" => {
3941            #[cfg(target_os = "windows")]
3942            {
3943                let cmd = format!("winget install --id {} -e --accept-package-agreements --accept-source-agreements", target);
3944                match Command::new("powershell")
3945                    .args(["-NoProfile", "-Command", &cmd])
3946                    .output()
3947                {
3948                    Ok(out) => Ok(format!(
3949                        "Executed remediation (winget install):\n{}",
3950                        String::from_utf8_lossy(&out.stdout)
3951                    )),
3952                    Err(e) => Err(format!("Failed to run winget: {}", e)),
3953                }
3954            }
3955            #[cfg(not(target_os = "windows"))]
3956            {
3957                Err(
3958                    "install_package via wrapper is only supported on Windows currently (winget)"
3959                        .to_string(),
3960                )
3961            }
3962        }
3963        "restart_service" => {
3964            #[cfg(target_os = "windows")]
3965            {
3966                let cmd = format!("Restart-Service -Name {} -Force", target);
3967                match Command::new("powershell")
3968                    .args(["-NoProfile", "-Command", &cmd])
3969                    .output()
3970                {
3971                    Ok(out) => {
3972                        let err_str = String::from_utf8_lossy(&out.stderr);
3973                        if !err_str.is_empty() {
3974                            return Err(format!("Error restarting service:\n{}", err_str));
3975                        }
3976                        Ok(format!("Successfully restarted service: {}", target))
3977                    }
3978                    Err(e) => Err(format!("Failed to restart service: {}", e)),
3979                }
3980            }
3981            #[cfg(not(target_os = "windows"))]
3982            {
3983                Err(
3984                    "restart_service via wrapper is only supported on Windows currently"
3985                        .to_string(),
3986                )
3987            }
3988        }
3989        "clear_temp" => {
3990            #[cfg(target_os = "windows")]
3991            {
3992                let cmd = "Remove-Item -Path \"$env:TEMP\\*\" -Recurse -Force -ErrorAction SilentlyContinue";
3993                match Command::new("powershell")
3994                    .args(["-NoProfile", "-Command", cmd])
3995                    .output()
3996                {
3997                    Ok(_) => Ok("Successfully cleared temporary files".to_string()),
3998                    Err(e) => Err(format!("Failed to clear temp: {}", e)),
3999                }
4000            }
4001            #[cfg(not(target_os = "windows"))]
4002            {
4003                Err("clear_temp via wrapper is only supported on Windows currently".to_string())
4004            }
4005        }
4006        other => Err(format!("Unknown remediation action: {}", other)),
4007    }
4008}
4009
4010// ── storage ───────────────────────────────────────────────────────────────────
4011
4012fn inspect_storage(max_entries: usize) -> Result<String, String> {
4013    let mut out = String::from("Host inspection: storage\n\n");
4014    let _ = max_entries; // used by non-Windows branch
4015
4016    // ── Drive overview ────────────────────────────────────────────────────────
4017    out.push_str("Drives:\n");
4018
4019    #[cfg(target_os = "windows")]
4020    {
4021        let script = r#"Get-PSDrive -PSProvider 'FileSystem' | ForEach-Object {
4022    $free = $_.Free
4023    $used = $_.Used
4024    if ($free -eq $null) { $free = 0 }
4025    if ($used -eq $null) { $used = 0 }
4026    $total = $free + $used
4027    "$($_.Name)|$free|$used|$total"
4028}"#;
4029        match Command::new("powershell")
4030            .args(["-NoProfile", "-Command", script])
4031            .output()
4032        {
4033            Ok(o) => {
4034                let text = String::from_utf8_lossy(&o.stdout);
4035                let mut drive_count = 0usize;
4036                for line in text.lines() {
4037                    let parts: Vec<&str> = line.trim().split('|').collect();
4038                    if parts.len() == 4 {
4039                        let name = parts[0];
4040                        let free: u64 = parts[1].parse().unwrap_or(0);
4041                        let total: u64 = parts[3].parse().unwrap_or(0);
4042                        if total == 0 {
4043                            continue;
4044                        }
4045                        let free_gb = free / 1_073_741_824;
4046                        let total_gb = total / 1_073_741_824;
4047                        let used_pct = ((total - free) as f64 / total as f64 * 100.0) as u64;
4048                        let bar_len = 20usize;
4049                        let filled = (used_pct as usize * bar_len / 100).min(bar_len);
4050                        let bar: String = "#".repeat(filled) + &".".repeat(bar_len - filled);
4051                        let warn = if free_gb < 5 {
4052                            " [!] CRITICALLY LOW"
4053                        } else if free_gb < 15 {
4054                            " [-] LOW"
4055                        } else {
4056                            ""
4057                        };
4058                        out.push_str(&format!(
4059                            "  {name}:  [{bar}] {used_pct}% used — {free_gb} GB free of {total_gb} GB{warn}\n"
4060                        ));
4061                        drive_count += 1;
4062                    }
4063                }
4064                if drive_count == 0 {
4065                    out.push_str("  (could not enumerate drives)\n");
4066                }
4067            }
4068            Err(e) => out.push_str(&format!("  (drive scan failed: {e})\n")),
4069        }
4070
4071        // ── Real-time Performance (Latency) ──────────────────────────────────
4072        let latency_script = "Get-CimInstance Win32_PerfFormattedData_PerfDisk_PhysicalDisk -Filter \"Name='_Total'\" | Select-Object -ExpandProperty AvgDiskQueueLength";
4073        match Command::new("powershell")
4074            .args(["-NoProfile", "-Command", latency_script])
4075            .output()
4076        {
4077            Ok(o) => {
4078                let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
4079                if !text.is_empty() {
4080                    out.push_str("\nReal-time Disk Intensity:\n");
4081                    out.push_str(&format!("  Average Disk Queue Length: {text}\n"));
4082                    if let Ok(q) = text.parse::<f64>() {
4083                        if q > 2.0 {
4084                            out.push_str(
4085                                "  [!] WARNING: High disk latency detected (Queue Length > 2.0)\n",
4086                            );
4087                        } else {
4088                            out.push_str("  [~] Disk latency is within healthy bounds.\n");
4089                        }
4090                    }
4091                }
4092            }
4093            Err(_) => {}
4094        }
4095    }
4096
4097    #[cfg(not(target_os = "windows"))]
4098    {
4099        match Command::new("df")
4100            .args(["-h", "--output=target,size,avail,pcent"])
4101            .output()
4102        {
4103            Ok(o) => {
4104                let text = String::from_utf8_lossy(&o.stdout);
4105                let mut count = 0usize;
4106                for line in text.lines().skip(1) {
4107                    let cols: Vec<&str> = line.split_whitespace().collect();
4108                    if cols.len() >= 4 && !cols[0].starts_with("tmpfs") {
4109                        out.push_str(&format!(
4110                            "  {}  size: {}  avail: {}  used: {}\n",
4111                            cols[0], cols[1], cols[2], cols[3]
4112                        ));
4113                        count += 1;
4114                        if count >= max_entries {
4115                            break;
4116                        }
4117                    }
4118                }
4119            }
4120            Err(e) => out.push_str(&format!("  (df failed: {e})\n")),
4121        }
4122    }
4123
4124    // ── Large developer cache directories ─────────────────────────────────────
4125    out.push_str("\nLarge developer cache directories (if present):\n");
4126
4127    #[cfg(target_os = "windows")]
4128    {
4129        let home = std::env::var("USERPROFILE").unwrap_or_default();
4130        let check_dirs: &[(&str, &str)] = &[
4131            ("Temp", r"AppData\Local\Temp"),
4132            ("npm cache", r"AppData\Roaming\npm-cache"),
4133            ("Cargo registry", r".cargo\registry"),
4134            ("Cargo git", r".cargo\git"),
4135            ("pip cache", r"AppData\Local\pip\cache"),
4136            ("Yarn cache", r"AppData\Local\Yarn\Cache"),
4137            (".rustup toolchains", r".rustup\toolchains"),
4138            ("node_modules (home)", r"node_modules"),
4139        ];
4140
4141        let mut found_any = false;
4142        for (label, rel) in check_dirs {
4143            let full = format!(r"{}\{}", home, rel);
4144            let path = std::path::Path::new(&full);
4145            if path.exists() {
4146                // Quick size estimate via PowerShell (non-blocking cap at 5s)
4147                let size_script = format!(
4148                    r#"try {{ $s = (Get-ChildItem -Path '{}' -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum; [math]::Round($s/1MB,0) }} catch {{ '?' }}"#,
4149                    full.replace('\'', "''")
4150                );
4151                let size_mb = Command::new("powershell")
4152                    .args(["-NoProfile", "-Command", &size_script])
4153                    .output()
4154                    .ok()
4155                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
4156                    .unwrap_or_else(|| "?".to_string());
4157                out.push_str(&format!("  {label}: {size_mb} MB  ({full})\n"));
4158                found_any = true;
4159            }
4160        }
4161        if !found_any {
4162            out.push_str("  (none of the common cache directories found)\n");
4163        }
4164
4165        out.push_str("\nTip: to reclaim space, run inspect_host(topic=\"fix_plan\", issue=\"free up disk space\")\n");
4166    }
4167
4168    #[cfg(not(target_os = "windows"))]
4169    {
4170        let home = std::env::var("HOME").unwrap_or_default();
4171        let check_dirs: &[(&str, &str)] = &[
4172            ("npm cache", ".npm"),
4173            ("Cargo registry", ".cargo/registry"),
4174            ("pip cache", ".cache/pip"),
4175            (".rustup toolchains", ".rustup/toolchains"),
4176            ("Yarn cache", ".cache/yarn"),
4177        ];
4178        let mut found_any = false;
4179        for (label, rel) in check_dirs {
4180            let full = format!("{}/{}", home, rel);
4181            if std::path::Path::new(&full).exists() {
4182                let size = Command::new("du")
4183                    .args(["-sh", &full])
4184                    .output()
4185                    .ok()
4186                    .map(|o| {
4187                        let s = String::from_utf8_lossy(&o.stdout);
4188                        s.split_whitespace().next().unwrap_or("?").to_string()
4189                    })
4190                    .unwrap_or_else(|| "?".to_string());
4191                out.push_str(&format!("  {label}: {size}  ({full})\n"));
4192                found_any = true;
4193            }
4194        }
4195        if !found_any {
4196            out.push_str("  (none of the common cache directories found)\n");
4197        }
4198    }
4199
4200    Ok(out.trim_end().to_string())
4201}
4202
4203// ── hardware ──────────────────────────────────────────────────────────────────
4204
4205fn inspect_hardware() -> Result<String, String> {
4206    let mut out = String::from("Host inspection: hardware\n\n");
4207
4208    #[cfg(target_os = "windows")]
4209    {
4210        // CPU
4211        let cpu_script = r#"Get-CimInstance Win32_Processor | ForEach-Object {
4212    "$($_.Name.Trim())|$($_.NumberOfCores)|$($_.NumberOfLogicalProcessors)|$([math]::Round($_.MaxClockSpeed/1000,1))"
4213} | Select-Object -First 1"#;
4214        if let Ok(o) = Command::new("powershell")
4215            .args(["-NoProfile", "-Command", cpu_script])
4216            .output()
4217        {
4218            let text = String::from_utf8_lossy(&o.stdout);
4219            let text = text.trim();
4220            let parts: Vec<&str> = text.split('|').collect();
4221            if parts.len() == 4 {
4222                out.push_str(&format!(
4223                    "CPU: {}\n  {} physical cores, {} logical processors, {:.1} GHz\n\n",
4224                    parts[0],
4225                    parts[1],
4226                    parts[2],
4227                    parts[3].parse::<f32>().unwrap_or(0.0)
4228                ));
4229            } else {
4230                out.push_str(&format!("CPU: {text}\n\n"));
4231            }
4232        }
4233
4234        // RAM (total installed + speed)
4235        let ram_script = r#"$sticks = Get-CimInstance Win32_PhysicalMemory
4236$total = ($sticks | Measure-Object Capacity -Sum).Sum / 1GB
4237$speed = ($sticks | Select-Object -First 1).Speed
4238"$([math]::Round($total,0)) GB @ $($speed) MHz ($($sticks.Count) stick(s))""#;
4239        if let Ok(o) = Command::new("powershell")
4240            .args(["-NoProfile", "-Command", ram_script])
4241            .output()
4242        {
4243            let text = String::from_utf8_lossy(&o.stdout);
4244            out.push_str(&format!("RAM: {}\n\n", text.trim().trim_matches('"')));
4245        }
4246
4247        // GPU(s)
4248        let gpu_script = r#"Get-CimInstance Win32_VideoController | ForEach-Object {
4249    "$($_.Name)|$($_.DriverVersion)|$($_.CurrentHorizontalResolution)x$($_.CurrentVerticalResolution)"
4250}"#;
4251        if let Ok(o) = Command::new("powershell")
4252            .args(["-NoProfile", "-Command", gpu_script])
4253            .output()
4254        {
4255            let text = String::from_utf8_lossy(&o.stdout);
4256            let lines: Vec<&str> = text.lines().collect();
4257            if !lines.is_empty() {
4258                out.push_str("GPU(s):\n");
4259                for line in lines.iter().filter(|l| !l.trim().is_empty()) {
4260                    let parts: Vec<&str> = line.trim().split('|').collect();
4261                    if parts.len() == 3 {
4262                        let res = if parts[2] == "x" || parts[2].starts_with('0') {
4263                            String::new()
4264                        } else {
4265                            format!(" — {}@display", parts[2])
4266                        };
4267                        out.push_str(&format!(
4268                            "  {}\n    Driver: {}{}\n",
4269                            parts[0], parts[1], res
4270                        ));
4271                    } else {
4272                        out.push_str(&format!("  {}\n", line.trim()));
4273                    }
4274                }
4275                out.push('\n');
4276            }
4277        }
4278
4279        // Motherboard + BIOS + Virtualization
4280        let mb_script = r#"$mb = Get-CimInstance Win32_BaseBoard
4281$bios = Get-CimInstance Win32_BIOS
4282$cs = Get-CimInstance Win32_ComputerSystem
4283$proc = Get-CimInstance Win32_Processor | Select-Object -First 1
4284$virt = "Hypervisor: $($cs.HypervisorPresent)|SLAT: $($proc.SecondLevelAddressTranslationExtensions)"
4285"$($mb.Manufacturer.Trim()) $($mb.Product.Trim())|BIOS: $($bios.Manufacturer.Trim()) $($bios.SMBIOSBIOSVersion.Trim()) ($($bios.ReleaseDate))|$virt""#;
4286        if let Ok(o) = Command::new("powershell")
4287            .args(["-NoProfile", "-Command", mb_script])
4288            .output()
4289        {
4290            let text = String::from_utf8_lossy(&o.stdout);
4291            let text = text.trim().trim_matches('"');
4292            let parts: Vec<&str> = text.split('|').collect();
4293            if parts.len() == 4 {
4294                out.push_str(&format!(
4295                    "Motherboard: {}\n{}\nVirtualization: {}, {}\n\n",
4296                    parts[0].trim(),
4297                    parts[1].trim(),
4298                    parts[2].trim(),
4299                    parts[3].trim()
4300                ));
4301            }
4302        }
4303
4304        // Display(s)
4305        let disp_script = r#"Get-CimInstance Win32_DesktopMonitor | Where-Object {$_.ScreenWidth -gt 0} | ForEach-Object {
4306    "$($_.Name)|$($_.ScreenWidth)x$($_.ScreenHeight)"
4307}"#;
4308        if let Ok(o) = Command::new("powershell")
4309            .args(["-NoProfile", "-Command", disp_script])
4310            .output()
4311        {
4312            let text = String::from_utf8_lossy(&o.stdout);
4313            let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
4314            if !lines.is_empty() {
4315                out.push_str("Display(s):\n");
4316                for line in &lines {
4317                    let parts: Vec<&str> = line.trim().split('|').collect();
4318                    if parts.len() == 2 {
4319                        out.push_str(&format!("  {} — {}\n", parts[0].trim(), parts[1]));
4320                    }
4321                }
4322            }
4323        }
4324    }
4325
4326    #[cfg(not(target_os = "windows"))]
4327    {
4328        // CPU via /proc/cpuinfo
4329        if let Ok(content) = std::fs::read_to_string("/proc/cpuinfo") {
4330            let model = content
4331                .lines()
4332                .find(|l| l.starts_with("model name"))
4333                .and_then(|l| l.split(':').nth(1))
4334                .map(str::trim)
4335                .unwrap_or("unknown");
4336            let cores = content
4337                .lines()
4338                .filter(|l| l.starts_with("processor"))
4339                .count();
4340            out.push_str(&format!("CPU: {model}\n  {cores} logical processors\n\n"));
4341        }
4342
4343        // RAM
4344        if let Ok(content) = std::fs::read_to_string("/proc/meminfo") {
4345            let total_kb: u64 = content
4346                .lines()
4347                .find(|l| l.starts_with("MemTotal:"))
4348                .and_then(|l| l.split_whitespace().nth(1))
4349                .and_then(|v| v.parse().ok())
4350                .unwrap_or(0);
4351            let total_gb = total_kb / 1_048_576;
4352            out.push_str(&format!("RAM: {total_gb} GB total\n\n"));
4353        }
4354
4355        // GPU via lspci
4356        if let Ok(o) = Command::new("lspci").args(["-vmm"]).output() {
4357            let text = String::from_utf8_lossy(&o.stdout);
4358            let gpu_lines: Vec<&str> = text
4359                .lines()
4360                .filter(|l| l.contains("VGA") || l.contains("Display") || l.contains("3D"))
4361                .collect();
4362            if !gpu_lines.is_empty() {
4363                out.push_str("GPU(s):\n");
4364                for l in gpu_lines {
4365                    out.push_str(&format!("  {l}\n"));
4366                }
4367                out.push('\n');
4368            }
4369        }
4370
4371        // DMI/BIOS info
4372        if let Ok(o) = Command::new("dmidecode")
4373            .args(["-t", "baseboard", "-t", "bios"])
4374            .output()
4375        {
4376            let text = String::from_utf8_lossy(&o.stdout);
4377            out.push_str("Motherboard/BIOS:\n");
4378            for line in text
4379                .lines()
4380                .filter(|l| {
4381                    l.contains("Manufacturer:")
4382                        || l.contains("Product Name:")
4383                        || l.contains("Version:")
4384                })
4385                .take(6)
4386            {
4387                out.push_str(&format!("  {}\n", line.trim()));
4388            }
4389        }
4390    }
4391
4392    Ok(out.trim_end().to_string())
4393}
4394
4395// ── updates ───────────────────────────────────────────────────────────────────
4396
4397fn inspect_updates() -> Result<String, String> {
4398    let mut out = String::from("Host inspection: updates\n\n");
4399
4400    #[cfg(target_os = "windows")]
4401    {
4402        // Last installed update via COM
4403        let script = r#"
4404try {
4405    $sess = New-Object -ComObject Microsoft.Update.Session
4406    $searcher = $sess.CreateUpdateSearcher()
4407    $count = $searcher.GetTotalHistoryCount()
4408    if ($count -gt 0) {
4409        $latest = $searcher.QueryHistory(0, 1) | Select-Object -First 1
4410        $latest.Date.ToString("yyyy-MM-dd HH:mm") + "|LAST_INSTALL"
4411    } else { "NONE|LAST_INSTALL" }
4412} catch { "ERROR:" + $_.Exception.Message + "|LAST_INSTALL" }
4413"#;
4414        if let Ok(o) = Command::new("powershell")
4415            .args(["-NoProfile", "-Command", script])
4416            .output()
4417        {
4418            let raw = String::from_utf8_lossy(&o.stdout);
4419            let text = raw.trim();
4420            if text.starts_with("ERROR:") {
4421                out.push_str("Last update install: (unable to query)\n");
4422            } else if text.contains("NONE") {
4423                out.push_str("Last update install: No update history found\n");
4424            } else {
4425                let date = text.replace("|LAST_INSTALL", "");
4426                out.push_str(&format!("Last update install: {date}\n"));
4427            }
4428        }
4429
4430        // Pending updates count
4431        let pending_script = r#"
4432try {
4433    $sess = New-Object -ComObject Microsoft.Update.Session
4434    $searcher = $sess.CreateUpdateSearcher()
4435    $results = $searcher.Search("IsInstalled=0 and IsHidden=0 and Type='Software'")
4436    $results.Updates.Count.ToString() + "|PENDING"
4437} catch { "ERROR:" + $_.Exception.Message + "|PENDING" }
4438"#;
4439        if let Ok(o) = Command::new("powershell")
4440            .args(["-NoProfile", "-Command", pending_script])
4441            .output()
4442        {
4443            let raw = String::from_utf8_lossy(&o.stdout);
4444            let text = raw.trim();
4445            if text.starts_with("ERROR:") {
4446                out.push_str("Pending updates: (unable to query via COM — try opening Windows Update manually)\n");
4447            } else {
4448                let count: i64 = text.replace("|PENDING", "").trim().parse().unwrap_or(-1);
4449                if count == 0 {
4450                    out.push_str("Pending updates: Up to date — no updates waiting\n");
4451                } else if count > 0 {
4452                    out.push_str(&format!("Pending updates: {count} update(s) available\n"));
4453                    out.push_str(
4454                        "  → Open Windows Update (Settings > Windows Update) to install\n",
4455                    );
4456                }
4457            }
4458        }
4459
4460        // Windows Update service state
4461        let svc_script = r#"
4462$svc = Get-Service -Name wuauserv -ErrorAction SilentlyContinue
4463if ($svc) { $svc.Status.ToString() } else { "NOT_FOUND" }
4464"#;
4465        if let Ok(o) = Command::new("powershell")
4466            .args(["-NoProfile", "-Command", svc_script])
4467            .output()
4468        {
4469            let raw = String::from_utf8_lossy(&o.stdout);
4470            let status = raw.trim();
4471            out.push_str(&format!("Windows Update service: {status}\n"));
4472        }
4473    }
4474
4475    #[cfg(not(target_os = "windows"))]
4476    {
4477        let apt_out = Command::new("apt").args(["list", "--upgradable"]).output();
4478        let mut found = false;
4479        if let Ok(o) = apt_out {
4480            let text = String::from_utf8_lossy(&o.stdout);
4481            let lines: Vec<&str> = text
4482                .lines()
4483                .filter(|l| l.contains('/') && !l.contains("Listing"))
4484                .collect();
4485            if !lines.is_empty() {
4486                out.push_str(&format!(
4487                    "{} package(s) can be upgraded (apt)\n",
4488                    lines.len()
4489                ));
4490                out.push_str("  → Run: sudo apt upgrade\n");
4491                found = true;
4492            }
4493        }
4494        if !found {
4495            if let Ok(o) = Command::new("dnf")
4496                .args(["check-update", "--quiet"])
4497                .output()
4498            {
4499                let text = String::from_utf8_lossy(&o.stdout);
4500                let count = text
4501                    .lines()
4502                    .filter(|l| !l.is_empty() && !l.starts_with('!'))
4503                    .count();
4504                if count > 0 {
4505                    out.push_str(&format!("{count} package(s) can be upgraded (dnf)\n"));
4506                    out.push_str("  → Run: sudo dnf upgrade\n");
4507                } else {
4508                    out.push_str("System is up to date.\n");
4509                }
4510            } else {
4511                out.push_str("Could not query package manager for updates.\n");
4512            }
4513        }
4514    }
4515
4516    Ok(out.trim_end().to_string())
4517}
4518
4519// ── security ──────────────────────────────────────────────────────────────────
4520
4521fn inspect_security() -> Result<String, String> {
4522    let mut out = String::from("Host inspection: security\n\n");
4523
4524    #[cfg(target_os = "windows")]
4525    {
4526        // Windows Defender status
4527        let defender_script = r#"
4528try {
4529    $status = Get-MpComputerStatus -ErrorAction Stop
4530    "RTP:" + $status.RealTimeProtectionEnabled + "|SCAN:" + $status.QuickScanEndTime.ToString("yyyy-MM-dd HH:mm") + "|VER:" + $status.AntivirusSignatureVersion + "|AGE:" + $status.AntivirusSignatureAge
4531} catch { "ERROR:" + $_.Exception.Message }
4532"#;
4533        if let Ok(o) = Command::new("powershell")
4534            .args(["-NoProfile", "-Command", defender_script])
4535            .output()
4536        {
4537            let raw = String::from_utf8_lossy(&o.stdout);
4538            let text = raw.trim();
4539            if text.starts_with("ERROR:") {
4540                out.push_str(&format!("Windows Defender: unable to query — {text}\n"));
4541            } else {
4542                let get = |key: &str| -> String {
4543                    text.split('|')
4544                        .find(|s| s.starts_with(key))
4545                        .and_then(|s| s.splitn(2, ':').nth(1))
4546                        .unwrap_or("unknown")
4547                        .to_string()
4548                };
4549                let rtp = get("RTP");
4550                let last_scan = {
4551                    // SCAN field has a colon in the time, so grab everything after "SCAN:"
4552                    text.split('|')
4553                        .find(|s| s.starts_with("SCAN:"))
4554                        .and_then(|s| s.get(5..))
4555                        .unwrap_or("unknown")
4556                        .to_string()
4557                };
4558                let def_ver = get("VER");
4559                let age_days: i64 = get("AGE").parse().unwrap_or(-1);
4560
4561                let rtp_label = if rtp == "True" {
4562                    "ENABLED"
4563                } else {
4564                    "DISABLED [!]"
4565                };
4566                out.push_str(&format!(
4567                    "Windows Defender real-time protection: {rtp_label}\n"
4568                ));
4569                out.push_str(&format!("Last quick scan: {last_scan}\n"));
4570                out.push_str(&format!("Signature version: {def_ver}\n"));
4571                if age_days >= 0 {
4572                    let freshness = if age_days == 0 {
4573                        "up to date".to_string()
4574                    } else if age_days <= 3 {
4575                        format!("{age_days} day(s) old — OK")
4576                    } else if age_days <= 7 {
4577                        format!("{age_days} day(s) old — consider updating")
4578                    } else {
4579                        format!("{age_days} day(s) old — [!] STALE, run Windows Update")
4580                    };
4581                    out.push_str(&format!("Signature age: {freshness}\n"));
4582                }
4583                if rtp != "True" {
4584                    out.push_str(
4585                        "\n[!] Real-time protection is OFF — your PC is not actively protected.\n",
4586                    );
4587                    out.push_str(
4588                        "    → Open Windows Security > Virus & threat protection to re-enable.\n",
4589                    );
4590                }
4591            }
4592        }
4593
4594        out.push('\n');
4595
4596        // Windows Firewall state
4597        let fw_script = r#"
4598try {
4599    Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object { $_.Name + ":" + $_.Enabled }
4600} catch { "ERROR:" + $_.Exception.Message }
4601"#;
4602        if let Ok(o) = Command::new("powershell")
4603            .args(["-NoProfile", "-Command", fw_script])
4604            .output()
4605        {
4606            let raw = String::from_utf8_lossy(&o.stdout);
4607            let text = raw.trim();
4608            if !text.starts_with("ERROR:") && !text.is_empty() {
4609                out.push_str("Windows Firewall:\n");
4610                for line in text.lines() {
4611                    if let Some((name, enabled)) = line.split_once(':') {
4612                        let state = if enabled.trim() == "True" {
4613                            "ON"
4614                        } else {
4615                            "OFF [!]"
4616                        };
4617                        out.push_str(&format!("  {name}: {state}\n"));
4618                    }
4619                }
4620                out.push('\n');
4621            }
4622        }
4623
4624        // Windows activation status
4625        let act_script = r#"
4626try {
4627    $lic = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%' and LicenseStatus=1" -ErrorAction Stop | Select-Object -First 1
4628    if ($lic) { "ACTIVATED" } else { "NOT_ACTIVATED" }
4629} catch { "UNKNOWN" }
4630"#;
4631        if let Ok(o) = Command::new("powershell")
4632            .args(["-NoProfile", "-Command", act_script])
4633            .output()
4634        {
4635            let raw = String::from_utf8_lossy(&o.stdout);
4636            match raw.trim() {
4637                "ACTIVATED" => out.push_str("Windows activation: Activated\n"),
4638                "NOT_ACTIVATED" => out.push_str("Windows activation: [!] NOT ACTIVATED\n"),
4639                _ => out.push_str("Windows activation: Unable to determine\n"),
4640            }
4641        }
4642
4643        // UAC state
4644        let uac_script = r#"
4645$val = Get-ItemPropertyValue 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue
4646if ($val -eq 1) { "ON" } else { "OFF" }
4647"#;
4648        if let Ok(o) = Command::new("powershell")
4649            .args(["-NoProfile", "-Command", uac_script])
4650            .output()
4651        {
4652            let raw = String::from_utf8_lossy(&o.stdout);
4653            let state = raw.trim();
4654            let label = if state == "ON" {
4655                "Enabled"
4656            } else {
4657                "DISABLED [!] — recommended to re-enable via secpol.msc"
4658            };
4659            out.push_str(&format!("UAC (User Account Control): {label}\n"));
4660        }
4661    }
4662
4663    #[cfg(not(target_os = "windows"))]
4664    {
4665        if let Ok(o) = Command::new("ufw").arg("status").output() {
4666            let text = String::from_utf8_lossy(&o.stdout);
4667            out.push_str(&format!(
4668                "UFW: {}\n",
4669                text.lines().next().unwrap_or("unknown")
4670            ));
4671        }
4672        if let Ok(cfg) = std::fs::read_to_string("/etc/selinux/config") {
4673            if let Some(line) = cfg.lines().find(|l| l.starts_with("SELINUX=")) {
4674                out.push_str(&format!("{line}\n"));
4675            }
4676        }
4677    }
4678
4679    Ok(out.trim_end().to_string())
4680}
4681
4682// ── pending_reboot ────────────────────────────────────────────────────────────
4683
4684fn inspect_pending_reboot() -> Result<String, String> {
4685    let mut out = String::from("Host inspection: pending_reboot\n\n");
4686
4687    #[cfg(target_os = "windows")]
4688    {
4689        let script = r#"
4690$reasons = @()
4691if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
4692    $reasons += "Windows Update requires a restart"
4693}
4694if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
4695    $reasons += "Windows component install/update requires a restart"
4696}
4697$pfro = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
4698if ($pfro -and $pfro.PendingFileRenameOperations) {
4699    $reasons += "Pending file rename operations (driver or system file replacement)"
4700}
4701if ($reasons.Count -eq 0) { "NO_REBOOT_NEEDED" } else { $reasons -join "|REASON|" }
4702"#;
4703        let output = Command::new("powershell")
4704            .args(["-NoProfile", "-Command", script])
4705            .output()
4706            .map_err(|e| format!("pending_reboot: {e}"))?;
4707
4708        let raw = String::from_utf8_lossy(&output.stdout);
4709        let text = raw.trim();
4710
4711        if text == "NO_REBOOT_NEEDED" {
4712            out.push_str("No restart required — system is up to date and stable.\n");
4713        } else if text.is_empty() {
4714            out.push_str("Could not determine reboot status.\n");
4715        } else {
4716            out.push_str("[!] A system restart is pending:\n\n");
4717            for reason in text.split("|REASON|") {
4718                out.push_str(&format!("  • {}\n", reason.trim()));
4719            }
4720            out.push_str("\nRecommendation: Save your work and restart when convenient.\n");
4721        }
4722    }
4723
4724    #[cfg(not(target_os = "windows"))]
4725    {
4726        if std::path::Path::new("/var/run/reboot-required").exists() {
4727            out.push_str("[!] A restart is required (see /var/run/reboot-required)\n");
4728            if let Ok(pkgs) = std::fs::read_to_string("/var/run/reboot-required.pkgs") {
4729                out.push_str("Packages requiring restart:\n");
4730                for p in pkgs.lines().take(10) {
4731                    out.push_str(&format!("  • {p}\n"));
4732                }
4733            }
4734        } else {
4735            out.push_str("No restart required.\n");
4736        }
4737    }
4738
4739    Ok(out.trim_end().to_string())
4740}
4741
4742// ── disk_health ───────────────────────────────────────────────────────────────
4743
4744fn inspect_disk_health() -> Result<String, String> {
4745    let mut out = String::from("Host inspection: disk_health\n\n");
4746
4747    #[cfg(target_os = "windows")]
4748    {
4749        let script = r#"
4750try {
4751    $disks = Get-PhysicalDisk -ErrorAction Stop
4752    foreach ($d in $disks) {
4753        $size_gb = [math]::Round($d.Size / 1GB, 0)
4754        $d.FriendlyName + "|" + $d.MediaType + "|" + $size_gb + "GB|" + $d.HealthStatus + "|" + $d.OperationalStatus
4755    }
4756} catch { "ERROR:" + $_.Exception.Message }
4757"#;
4758        let output = Command::new("powershell")
4759            .args(["-NoProfile", "-Command", script])
4760            .output()
4761            .map_err(|e| format!("disk_health: {e}"))?;
4762
4763        let raw = String::from_utf8_lossy(&output.stdout);
4764        let text = raw.trim();
4765
4766        if text.starts_with("ERROR:") {
4767            out.push_str(&format!("Unable to query disk health: {text}\n"));
4768            out.push_str("This may require running as administrator.\n");
4769        } else if text.is_empty() {
4770            out.push_str("No physical disks found.\n");
4771        } else {
4772            out.push_str("Physical Drive Health:\n\n");
4773            for line in text.lines() {
4774                let parts: Vec<&str> = line.splitn(5, '|').collect();
4775                if parts.len() >= 4 {
4776                    let name = parts[0];
4777                    let media = parts[1];
4778                    let size = parts[2];
4779                    let health = parts[3];
4780                    let op_status = parts.get(4).unwrap_or(&"");
4781                    let health_label = match health.trim() {
4782                        "Healthy" => "OK",
4783                        "Warning" => "[!] WARNING",
4784                        "Unhealthy" => "[!!] UNHEALTHY — BACK UP YOUR DATA NOW",
4785                        other => other,
4786                    };
4787                    out.push_str(&format!("  {name}\n"));
4788                    out.push_str(&format!("    Type: {media} | Size: {size}\n"));
4789                    out.push_str(&format!("    Health: {health_label}\n"));
4790                    if !op_status.is_empty() {
4791                        out.push_str(&format!("    Status: {op_status}\n"));
4792                    }
4793                    out.push('\n');
4794                }
4795            }
4796        }
4797
4798        // SMART failure prediction (best-effort, may need admin)
4799        let smart_script = r#"
4800try {
4801    Get-WmiObject -Class MSStorageDriver_FailurePredictStatus -Namespace root\wmi -ErrorAction Stop |
4802        ForEach-Object { $_.InstanceName + "|" + $_.PredictFailure }
4803} catch { "" }
4804"#;
4805        if let Ok(o) = Command::new("powershell")
4806            .args(["-NoProfile", "-Command", smart_script])
4807            .output()
4808        {
4809            let raw2 = String::from_utf8_lossy(&o.stdout);
4810            let text2 = raw2.trim();
4811            if !text2.is_empty() {
4812                let failures: Vec<&str> = text2.lines().filter(|l| l.contains("|True")).collect();
4813                if failures.is_empty() {
4814                    out.push_str("SMART failure prediction: No failures predicted\n");
4815                } else {
4816                    out.push_str("[!!] SMART failure predicted on one or more drives:\n");
4817                    for f in failures {
4818                        let name = f.split('|').next().unwrap_or(f);
4819                        out.push_str(&format!("  • {name}\n"));
4820                    }
4821                    out.push_str(
4822                        "\nBack up your data immediately and replace the failing drive.\n",
4823                    );
4824                }
4825            }
4826        }
4827    }
4828
4829    #[cfg(not(target_os = "windows"))]
4830    {
4831        if let Ok(o) = Command::new("lsblk")
4832            .args(["-d", "-o", "NAME,SIZE,TYPE,ROTA,MODEL"])
4833            .output()
4834        {
4835            let text = String::from_utf8_lossy(&o.stdout);
4836            out.push_str("Block devices:\n");
4837            out.push_str(text.trim());
4838            out.push('\n');
4839        }
4840        if let Ok(scan) = Command::new("smartctl").args(["--scan"]).output() {
4841            let devices = String::from_utf8_lossy(&scan.stdout);
4842            for dev_line in devices.lines().take(4) {
4843                let dev = dev_line.split_whitespace().next().unwrap_or("");
4844                if dev.is_empty() {
4845                    continue;
4846                }
4847                if let Ok(o) = Command::new("smartctl").args(["-H", dev]).output() {
4848                    let health = String::from_utf8_lossy(&o.stdout);
4849                    if let Some(line) = health.lines().find(|l| l.contains("SMART overall-health"))
4850                    {
4851                        out.push_str(&format!("{dev}: {}\n", line.trim()));
4852                    }
4853                }
4854            }
4855        } else {
4856            out.push_str("(install smartmontools for SMART health data)\n");
4857        }
4858    }
4859
4860    Ok(out.trim_end().to_string())
4861}
4862
4863// ── battery ───────────────────────────────────────────────────────────────────
4864
4865fn inspect_battery() -> Result<String, String> {
4866    let mut out = String::from("Host inspection: battery\n\n");
4867
4868    #[cfg(target_os = "windows")]
4869    {
4870        let script = r#"
4871try {
4872    $bats = Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue
4873    if (-not $bats) { "NO_BATTERY"; exit }
4874    
4875    # Modern Battery Health (Cycle count + Capacity health)
4876    $static = Get-CimInstance -Namespace root/WMI -ClassName BatteryStaticData -ErrorAction SilentlyContinue
4877    $full = Get-CimInstance -Namespace root/WMI -ClassName BatteryFullCapacity -ErrorAction SilentlyContinue 
4878    $status = Get-CimInstance -Namespace root/WMI -ClassName BatteryStatus -ErrorAction SilentlyContinue
4879
4880    foreach ($b in $bats) {
4881        $state = switch ($b.BatteryStatus) {
4882            1 { "Discharging" }
4883            2 { "AC Power (Fully Charged)" }
4884            3 { "AC Power (Charging)" }
4885            default { "Status $($b.BatteryStatus)" }
4886        }
4887        
4888        $cycles = if ($status) { $status.CycleCount } else { "unknown" }
4889        $health = if ($static -and $full) {
4890             [math]::Round(($full.FullChargeCapacity / $static.DesignCapacity) * 100, 1)
4891        } else { "unknown" }
4892
4893        $b.Name + "|" + $b.EstimatedChargeRemaining + "|" + $state + "|" + $cycles + "|" + $health
4894    }
4895} catch { "ERROR:" + $_.Exception.Message }
4896"#;
4897        let output = Command::new("powershell")
4898            .args(["-NoProfile", "-Command", script])
4899            .output()
4900            .map_err(|e| format!("battery: {e}"))?;
4901
4902        let raw = String::from_utf8_lossy(&output.stdout);
4903        let text = raw.trim();
4904
4905        if text == "NO_BATTERY" {
4906            out.push_str("No battery detected — desktop or AC-only system.\n");
4907            return Ok(out.trim_end().to_string());
4908        }
4909        if text.starts_with("ERROR:") {
4910            out.push_str(&format!("Unable to query battery: {text}\n"));
4911            return Ok(out.trim_end().to_string());
4912        }
4913
4914        for line in text.lines() {
4915            let parts: Vec<&str> = line.split('|').collect();
4916            if parts.len() == 5 {
4917                let name = parts[0];
4918                let charge: i64 = parts[1].parse().unwrap_or(-1);
4919                let state = parts[2];
4920                let cycles = parts[3];
4921                let health = parts[4];
4922
4923                out.push_str(&format!("Battery: {name}\n"));
4924                if charge >= 0 {
4925                    let bar_filled = (charge as usize * 20) / 100;
4926                    out.push_str(&format!(
4927                        "  Charge: [{}{}] {}%\n",
4928                        "#".repeat(bar_filled),
4929                        ".".repeat(20 - bar_filled),
4930                        charge
4931                    ));
4932                }
4933                out.push_str(&format!("  Status: {state}\n"));
4934                out.push_str(&format!("  Cycles: {cycles}\n"));
4935                out.push_str(&format!("  Health: {health}% (Actual vs Design Capacity)\n\n"));
4936            }
4937        }
4938    }
4939
4940    #[cfg(not(target_os = "windows"))]
4941    {
4942        let power_path = std::path::Path::new("/sys/class/power_supply");
4943        let mut found = false;
4944        if power_path.exists() {
4945            if let Ok(entries) = std::fs::read_dir(power_path) {
4946                for entry in entries.flatten() {
4947                    let p = entry.path();
4948                    if let Ok(t) = std::fs::read_to_string(p.join("type")) {
4949                        if t.trim() == "Battery" {
4950                            found = true;
4951                            let name = p
4952                                .file_name()
4953                                .unwrap_or_default()
4954                                .to_string_lossy()
4955                                .to_string();
4956                            out.push_str(&format!("Battery: {name}\n"));
4957                            let read = |f: &str| {
4958                                std::fs::read_to_string(p.join(f))
4959                                    .ok()
4960                                    .map(|s| s.trim().to_string())
4961                            };
4962                            if let Some(cap) = read("capacity") {
4963                                out.push_str(&format!("  Charge: {cap}%\n"));
4964                            }
4965                            if let Some(status) = read("status") {
4966                                out.push_str(&format!("  Status: {status}\n"));
4967                            }
4968                            if let (Some(full), Some(design)) =
4969                                (read("energy_full"), read("energy_full_design"))
4970                            {
4971                                if let (Ok(f), Ok(d)) = (full.parse::<f64>(), design.parse::<f64>())
4972                                {
4973                                    if d > 0.0 {
4974                                        out.push_str(&format!(
4975                                            "  Wear level: {:.1}% of design capacity\n",
4976                                            (f / d) * 100.0
4977                                        ));
4978                                    }
4979                                }
4980                            }
4981                        }
4982                    }
4983                }
4984            }
4985        }
4986        if !found {
4987            out.push_str("No battery found.\n");
4988        }
4989    }
4990
4991    Ok(out.trim_end().to_string())
4992}
4993
4994// ── recent_crashes ────────────────────────────────────────────────────────────
4995
4996fn inspect_recent_crashes(max_entries: usize) -> Result<String, String> {
4997    let mut out = String::from("Host inspection: recent_crashes\n\n");
4998    let n = max_entries.clamp(1, 30);
4999
5000    #[cfg(target_os = "windows")]
5001    {
5002        // BSODs / unexpected shutdowns (EventID 41 = kernel power, 1001 = BugCheck)
5003        let bsod_script = format!(
5004            r#"
5005try {{
5006    $events = Get-WinEvent -FilterHashtable @{{LogName='System'; Id=41,1001}} -MaxEvents {n} -ErrorAction SilentlyContinue
5007    if ($events) {{
5008        $events | ForEach-Object {{
5009            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + $_.Id + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5010        }}
5011    }} else {{ "NO_BSOD" }}
5012}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5013        );
5014
5015        if let Ok(o) = Command::new("powershell")
5016            .args(["-NoProfile", "-Command", &bsod_script])
5017            .output()
5018        {
5019            let raw = String::from_utf8_lossy(&o.stdout);
5020            let text = raw.trim();
5021            if text == "NO_BSOD" {
5022                out.push_str("System crashes (BSOD/kernel): None in recent history\n");
5023            } else if text.starts_with("ERROR:") {
5024                out.push_str("System crashes: unable to query\n");
5025            } else {
5026                out.push_str("System crashes / unexpected shutdowns:\n");
5027                for line in text.lines() {
5028                    let parts: Vec<&str> = line.splitn(3, '|').collect();
5029                    if parts.len() >= 3 {
5030                        let time = parts[0];
5031                        let id = parts[1];
5032                        let msg = parts[2];
5033                        let label = if id == "41" {
5034                            "Unexpected shutdown"
5035                        } else {
5036                            "BSOD (BugCheck)"
5037                        };
5038                        out.push_str(&format!("  [{time}] {label}: {msg}\n"));
5039                    }
5040                }
5041                out.push('\n');
5042            }
5043        }
5044
5045        // Application crashes (EventID 1000 = app crash, 1002 = app hang)
5046        let app_script = format!(
5047            r#"
5048try {{
5049    $crashes = Get-WinEvent -FilterHashtable @{{LogName='Application'; Id=1000,1002}} -MaxEvents {n} -ErrorAction SilentlyContinue
5050    if ($crashes) {{
5051        $crashes | ForEach-Object {{
5052            $_.TimeCreated.ToString("yyyy-MM-dd HH:mm") + "|" + (($_.Message -split "[\r\n]")[0].Trim())
5053        }}
5054    }} else {{ "NO_CRASHES" }}
5055}} catch {{ "ERROR_APP:" + $_.Exception.Message }}"#
5056        );
5057
5058        if let Ok(o) = Command::new("powershell")
5059            .args(["-NoProfile", "-Command", &app_script])
5060            .output()
5061        {
5062            let raw = String::from_utf8_lossy(&o.stdout);
5063            let text = raw.trim();
5064            if text == "NO_CRASHES" {
5065                out.push_str("Application crashes: None in recent history\n");
5066            } else if text.starts_with("ERROR_APP:") {
5067                out.push_str("Application crashes: unable to query\n");
5068            } else {
5069                out.push_str("Application crashes:\n");
5070                for line in text.lines().take(n) {
5071                    let parts: Vec<&str> = line.splitn(2, '|').collect();
5072                    if parts.len() >= 2 {
5073                        out.push_str(&format!("  [{}] {}\n", parts[0], parts[1]));
5074                    }
5075                }
5076            }
5077        }
5078    }
5079
5080    #[cfg(not(target_os = "windows"))]
5081    {
5082        let n_str = n.to_string();
5083        if let Ok(o) = Command::new("journalctl")
5084            .args(["-k", "--no-pager", "-n", &n_str, "-p", "0..2"])
5085            .output()
5086        {
5087            let text = String::from_utf8_lossy(&o.stdout);
5088            let trimmed = text.trim();
5089            if trimmed.is_empty() || trimmed.contains("No entries") {
5090                out.push_str("No kernel panics or critical crashes found.\n");
5091            } else {
5092                out.push_str("Kernel critical events:\n");
5093                out.push_str(trimmed);
5094                out.push('\n');
5095            }
5096        }
5097        if let Ok(o) = Command::new("coredumpctl")
5098            .args(["list", "--no-pager"])
5099            .output()
5100        {
5101            let text = String::from_utf8_lossy(&o.stdout);
5102            let count = text
5103                .lines()
5104                .filter(|l| !l.trim().is_empty() && !l.starts_with("TIME"))
5105                .count();
5106            if count > 0 {
5107                out.push_str(&format!(
5108                    "\nCore dumps on file: {count}\n  → Run: coredumpctl list\n"
5109                ));
5110            }
5111        }
5112    }
5113
5114    Ok(out.trim_end().to_string())
5115}
5116
5117// ── scheduled_tasks ───────────────────────────────────────────────────────────
5118
5119fn inspect_scheduled_tasks(max_entries: usize) -> Result<String, String> {
5120    let mut out = String::from("Host inspection: scheduled_tasks\n\n");
5121    let n = max_entries.clamp(1, 30);
5122
5123    #[cfg(target_os = "windows")]
5124    {
5125        let script = format!(
5126            r#"
5127try {{
5128    $tasks = Get-ScheduledTask -ErrorAction Stop |
5129        Where-Object {{ $_.State -ne 'Disabled' }} |
5130        ForEach-Object {{
5131            $info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
5132            $lastRun = if ($info -and $info.LastRunTime -and $info.LastRunTime.Year -gt 2000) {{
5133                $info.LastRunTime.ToString("yyyy-MM-dd HH:mm")
5134            }} else {{ "never" }}
5135            $res = if ($info) {{ "0x{{0:x}}" -f $info.LastTaskResult }} else {{ "---" }}
5136            $exec = ($_.Actions | Select-Object -First 1).Execute
5137            if (-not $exec) {{ $exec = "(no exec)" }}
5138            $_.TaskName + "|" + $_.TaskPath + "|" + $_.State + "|" + $lastRun + "|" + $res + "|" + $exec
5139        }}
5140    $tasks | Select-Object -First {n}
5141}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5142        );
5143
5144        let output = Command::new("powershell")
5145            .args(["-NoProfile", "-Command", &script])
5146            .output()
5147            .map_err(|e| format!("scheduled_tasks: {e}"))?;
5148
5149        let raw = String::from_utf8_lossy(&output.stdout);
5150        let text = raw.trim();
5151
5152        if text.starts_with("ERROR:") {
5153            out.push_str(&format!("Unable to query scheduled tasks: {text}\n"));
5154        } else if text.is_empty() {
5155            out.push_str("No active scheduled tasks found.\n");
5156        } else {
5157            out.push_str(&format!("Active scheduled tasks (up to {n}):\n\n"));
5158            for line in text.lines() {
5159                let parts: Vec<&str> = line.splitn(6, '|').collect();
5160                if parts.len() >= 5 {
5161                    let name = parts[0];
5162                    let path = parts[1];
5163                    let state = parts[2];
5164                    let last = parts[3];
5165                    let res = parts[4];
5166                    let exec = parts.get(5).unwrap_or(&"").trim();
5167                    let display_path = path.trim_matches('\\');
5168                    let display_path = if display_path.is_empty() {
5169                        "Root"
5170                    } else {
5171                        display_path
5172                    };
5173                    out.push_str(&format!("  {name} [{display_path}]\n"));
5174                    out.push_str(&format!("    State: {state} | Last run: {last} | Result: {res}\n"));
5175                    if !exec.is_empty() && exec != "(no exec)" {
5176                        let short = if exec.len() > 80 { &exec[..80] } else { exec };
5177                        out.push_str(&format!("    Runs: {short}\n"));
5178                    }
5179                }
5180            }
5181        }
5182    }
5183
5184    #[cfg(not(target_os = "windows"))]
5185    {
5186        if let Ok(o) = Command::new("systemctl")
5187            .args(["list-timers", "--no-pager", "--all"])
5188            .output()
5189        {
5190            let text = String::from_utf8_lossy(&o.stdout);
5191            out.push_str("Systemd timers:\n");
5192            for l in text
5193                .lines()
5194                .filter(|l| {
5195                    !l.trim().is_empty() && !l.starts_with("NEXT") && !l.starts_with("timers")
5196                })
5197                .take(n)
5198            {
5199                out.push_str(&format!("  {l}\n"));
5200            }
5201            out.push('\n');
5202        }
5203        if let Ok(o) = Command::new("crontab").arg("-l").output() {
5204            let text = String::from_utf8_lossy(&o.stdout);
5205            let jobs: Vec<&str> = text
5206                .lines()
5207                .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
5208                .collect();
5209            if !jobs.is_empty() {
5210                out.push_str("User crontab:\n");
5211                for j in jobs.iter().take(n) {
5212                    out.push_str(&format!("  {j}\n"));
5213                }
5214            }
5215        }
5216    }
5217
5218    Ok(out.trim_end().to_string())
5219}
5220
5221// ── dev_conflicts ─────────────────────────────────────────────────────────────
5222
5223fn inspect_dev_conflicts() -> Result<String, String> {
5224    let mut out = String::from("Host inspection: dev_conflicts\n\n");
5225    let mut conflicts: Vec<String> = Vec::new();
5226    let mut notes: Vec<String> = Vec::new();
5227
5228    // ── Node.js / version managers ────────────────────────────────────────────
5229    {
5230        let node_ver = Command::new("node")
5231            .arg("--version")
5232            .output()
5233            .ok()
5234            .and_then(|o| String::from_utf8(o.stdout).ok())
5235            .map(|s| s.trim().to_string());
5236        let nvm_active = Command::new("nvm")
5237            .arg("current")
5238            .output()
5239            .ok()
5240            .and_then(|o| String::from_utf8(o.stdout).ok())
5241            .map(|s| s.trim().to_string())
5242            .filter(|s| !s.is_empty() && !s.contains("none") && !s.contains("No current"));
5243        let fnm_active = Command::new("fnm")
5244            .arg("current")
5245            .output()
5246            .ok()
5247            .and_then(|o| String::from_utf8(o.stdout).ok())
5248            .map(|s| s.trim().to_string())
5249            .filter(|s| !s.is_empty() && !s.contains("none"));
5250        let volta_active = Command::new("volta")
5251            .args(["which", "node"])
5252            .output()
5253            .ok()
5254            .and_then(|o| String::from_utf8(o.stdout).ok())
5255            .map(|s| s.trim().to_string())
5256            .filter(|s| !s.is_empty());
5257
5258        out.push_str("Node.js:\n");
5259        if let Some(ref v) = node_ver {
5260            out.push_str(&format!("  Active: {v}\n"));
5261        } else {
5262            out.push_str("  Not installed\n");
5263        }
5264        let managers: Vec<&str> = [
5265            nvm_active.as_deref(),
5266            fnm_active.as_deref(),
5267            volta_active.as_deref(),
5268        ]
5269        .iter()
5270        .filter_map(|x| *x)
5271        .collect();
5272        if managers.len() > 1 {
5273            conflicts.push(format!(
5274                "Multiple Node.js version managers detected (nvm/fnm/volta). Only one should be active to avoid PATH conflicts."
5275            ));
5276        } else if !managers.is_empty() {
5277            out.push_str(&format!("  Version manager: {}\n", managers[0]));
5278        }
5279        out.push('\n');
5280    }
5281
5282    // ── Python ────────────────────────────────────────────────────────────────
5283    {
5284        let py3 = Command::new("python3")
5285            .arg("--version")
5286            .output()
5287            .ok()
5288            .and_then(|o| {
5289                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5290                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5291                let v = if stdout.is_empty() { stderr } else { stdout };
5292                if v.is_empty() {
5293                    None
5294                } else {
5295                    Some(v)
5296                }
5297            });
5298        let py = Command::new("python")
5299            .arg("--version")
5300            .output()
5301            .ok()
5302            .and_then(|o| {
5303                let stdout = String::from_utf8_lossy(&o.stdout).trim().to_string();
5304                let stderr = String::from_utf8_lossy(&o.stderr).trim().to_string();
5305                let v = if stdout.is_empty() { stderr } else { stdout };
5306                if v.is_empty() {
5307                    None
5308                } else {
5309                    Some(v)
5310                }
5311            });
5312        let pyenv = Command::new("pyenv")
5313            .arg("version")
5314            .output()
5315            .ok()
5316            .and_then(|o| String::from_utf8(o.stdout).ok())
5317            .map(|s| s.trim().to_string())
5318            .filter(|s| !s.is_empty());
5319        let conda_env = std::env::var("CONDA_DEFAULT_ENV").ok();
5320
5321        out.push_str("Python:\n");
5322        match (&py3, &py) {
5323            (Some(v3), Some(v)) if v3 != v => {
5324                out.push_str(&format!("  python3: {v3}\n  python:  {v}\n"));
5325                if v.contains("2.") {
5326                    conflicts.push(
5327                        "python and python3 point to different major versions (2.x vs 3.x). Scripts using 'python' may break unexpectedly.".to_string()
5328                    );
5329                } else {
5330                    notes.push(
5331                        "python and python3 resolve to different minor versions.".to_string(),
5332                    );
5333                }
5334            }
5335            (Some(v3), None) => out.push_str(&format!("  python3: {v3}\n")),
5336            (None, Some(v)) => out.push_str(&format!("  python: {v}\n")),
5337            (Some(v3), Some(_)) => out.push_str(&format!("  {v3}\n")),
5338            (None, None) => out.push_str("  Not installed\n"),
5339        }
5340        if let Some(ref pe) = pyenv {
5341            out.push_str(&format!("  pyenv: {pe}\n"));
5342        }
5343        if let Some(env) = conda_env {
5344            if env == "base" {
5345                notes.push("Conda base environment is active — may shadow system Python. Run 'conda deactivate' if unexpected.".to_string());
5346            } else {
5347                out.push_str(&format!("  conda env: {env}\n"));
5348            }
5349        }
5350        out.push('\n');
5351    }
5352
5353    // ── Rust / Cargo ──────────────────────────────────────────────────────────
5354    {
5355        let toolchain = Command::new("rustup")
5356            .args(["show", "active-toolchain"])
5357            .output()
5358            .ok()
5359            .and_then(|o| String::from_utf8(o.stdout).ok())
5360            .map(|s| s.trim().to_string())
5361            .filter(|s| !s.is_empty());
5362        let cargo_ver = Command::new("cargo")
5363            .arg("--version")
5364            .output()
5365            .ok()
5366            .and_then(|o| String::from_utf8(o.stdout).ok())
5367            .map(|s| s.trim().to_string());
5368        let rustc_ver = Command::new("rustc")
5369            .arg("--version")
5370            .output()
5371            .ok()
5372            .and_then(|o| String::from_utf8(o.stdout).ok())
5373            .map(|s| s.trim().to_string());
5374
5375        out.push_str("Rust:\n");
5376        if let Some(ref t) = toolchain {
5377            out.push_str(&format!("  Active toolchain: {t}\n"));
5378        }
5379        if let Some(ref c) = cargo_ver {
5380            out.push_str(&format!("  {c}\n"));
5381        }
5382        if let Some(ref r) = rustc_ver {
5383            out.push_str(&format!("  {r}\n"));
5384        }
5385        if cargo_ver.is_none() && rustc_ver.is_none() {
5386            out.push_str("  Not installed\n");
5387        }
5388
5389        // Detect system rust that might shadow rustup
5390        #[cfg(not(target_os = "windows"))]
5391        if let Ok(o) = Command::new("which").arg("rustc").output() {
5392            let path = String::from_utf8_lossy(&o.stdout).trim().to_string();
5393            if !path.is_empty() && !path.contains(".cargo") && !path.contains("rustup") {
5394                conflicts.push(format!(
5395                    "rustc found at non-rustup path '{path}' — may conflict with rustup-managed toolchain"
5396                ));
5397            }
5398        }
5399        out.push('\n');
5400    }
5401
5402    // ── Git ───────────────────────────────────────────────────────────────────
5403    {
5404        let git_ver = Command::new("git")
5405            .arg("--version")
5406            .output()
5407            .ok()
5408            .and_then(|o| String::from_utf8(o.stdout).ok())
5409            .map(|s| s.trim().to_string());
5410        out.push_str("Git:\n");
5411        if let Some(ref v) = git_ver {
5412            out.push_str(&format!("  {v}\n"));
5413            let email = Command::new("git")
5414                .args(["config", "--global", "user.email"])
5415                .output()
5416                .ok()
5417                .and_then(|o| String::from_utf8(o.stdout).ok())
5418                .map(|s| s.trim().to_string());
5419            if let Some(ref e) = email {
5420                if e.is_empty() {
5421                    notes.push("Git user.email is not configured globally — commits may fail or use wrong identity.".to_string());
5422                } else {
5423                    out.push_str(&format!("  user.email: {e}\n"));
5424                }
5425            }
5426            let gpg_sign = Command::new("git")
5427                .args(["config", "--global", "commit.gpgsign"])
5428                .output()
5429                .ok()
5430                .and_then(|o| String::from_utf8(o.stdout).ok())
5431                .map(|s| s.trim().to_string());
5432            if gpg_sign.as_deref() == Some("true") {
5433                let key = Command::new("git")
5434                    .args(["config", "--global", "user.signingkey"])
5435                    .output()
5436                    .ok()
5437                    .and_then(|o| String::from_utf8(o.stdout).ok())
5438                    .map(|s| s.trim().to_string());
5439                if key.as_deref().map(|k| k.is_empty()).unwrap_or(true) {
5440                    conflicts.push("Git commit signing is enabled but no signing key is configured — commits will fail.".to_string());
5441                }
5442            }
5443        } else {
5444            out.push_str("  Not installed\n");
5445        }
5446        out.push('\n');
5447    }
5448
5449    // ── PATH duplicates ───────────────────────────────────────────────────────
5450    {
5451        let path_env = std::env::var("PATH").unwrap_or_default();
5452        let sep = if cfg!(windows) { ';' } else { ':' };
5453        let mut seen = HashSet::new();
5454        let mut dupes: Vec<String> = Vec::new();
5455        for p in path_env.split(sep) {
5456            let norm = p.trim().to_lowercase();
5457            if !norm.is_empty() && !seen.insert(norm) {
5458                dupes.push(p.to_string());
5459            }
5460        }
5461        if !dupes.is_empty() {
5462            let shown: Vec<&str> = dupes.iter().take(3).map(|s| s.as_str()).collect();
5463            notes.push(format!(
5464                "Duplicate PATH entries: {} {}",
5465                shown.join(", "),
5466                if dupes.len() > 3 {
5467                    format!("+{} more", dupes.len() - 3)
5468                } else {
5469                    String::new()
5470                }
5471            ));
5472        }
5473    }
5474
5475    // ── Summary ───────────────────────────────────────────────────────────────
5476    if conflicts.is_empty() && notes.is_empty() {
5477        out.push_str("No conflicts detected — dev environment looks clean.\n");
5478    } else {
5479        if !conflicts.is_empty() {
5480            out.push_str("CONFLICTS:\n");
5481            for c in &conflicts {
5482                out.push_str(&format!("  [!] {c}\n"));
5483            }
5484            out.push('\n');
5485        }
5486        if !notes.is_empty() {
5487            out.push_str("NOTES:\n");
5488            for n in &notes {
5489                out.push_str(&format!("  [-] {n}\n"));
5490            }
5491        }
5492    }
5493
5494    Ok(out.trim_end().to_string())
5495}
5496
5497// ── connectivity ──────────────────────────────────────────────────────────────
5498
5499fn inspect_connectivity() -> Result<String, String> {
5500    let mut out = String::from("Host inspection: connectivity\n\n");
5501
5502    #[cfg(target_os = "windows")]
5503    {
5504        let inet_script = r#"
5505try {
5506    $r = Test-NetConnection -ComputerName 8.8.8.8 -Port 53 -InformationLevel Quiet -WarningAction SilentlyContinue
5507    if ($r) { "REACHABLE" } else { "UNREACHABLE" }
5508} catch { "ERROR:" + $_.Exception.Message }
5509"#;
5510        if let Ok(o) = Command::new("powershell")
5511            .args(["-NoProfile", "-Command", inet_script])
5512            .output()
5513        {
5514            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5515            match text.as_str() {
5516                "REACHABLE" => out.push_str("Internet: reachable\n"),
5517                "UNREACHABLE" => out.push_str("Internet: unreachable [!]\n"),
5518                _ => out.push_str(&format!(
5519                    "Internet: {}\n",
5520                    text.trim_start_matches("ERROR:").trim()
5521                )),
5522            }
5523        }
5524
5525        let dns_script = r#"
5526try {
5527    Resolve-DnsName -Name "dns.google" -Type A -ErrorAction Stop | Out-Null
5528    "DNS:ok"
5529} catch { "DNS:fail:" + $_.Exception.Message }
5530"#;
5531        if let Ok(o) = Command::new("powershell")
5532            .args(["-NoProfile", "-Command", dns_script])
5533            .output()
5534        {
5535            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5536            if text == "DNS:ok" {
5537                out.push_str("DNS: resolving correctly\n");
5538            } else {
5539                let detail = text.trim_start_matches("DNS:fail:").trim();
5540                out.push_str(&format!("DNS: failed — {}\n", detail));
5541            }
5542        }
5543
5544        let gw_script = r#"
5545(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
5546"#;
5547        if let Ok(o) = Command::new("powershell")
5548            .args(["-NoProfile", "-Command", gw_script])
5549            .output()
5550        {
5551            let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
5552            if !gw.is_empty() && gw != "0.0.0.0" {
5553                out.push_str(&format!("Default gateway: {}\n", gw));
5554            }
5555        }
5556    }
5557
5558    #[cfg(not(target_os = "windows"))]
5559    {
5560        let reachable = Command::new("ping")
5561            .args(["-c", "1", "-W", "2", "8.8.8.8"])
5562            .output()
5563            .map(|o| o.status.success())
5564            .unwrap_or(false);
5565        out.push_str(if reachable {
5566            "Internet: reachable\n"
5567        } else {
5568            "Internet: unreachable\n"
5569        });
5570        let dns_ok = Command::new("getent")
5571            .args(["hosts", "dns.google"])
5572            .output()
5573            .map(|o| o.status.success())
5574            .unwrap_or(false);
5575        out.push_str(if dns_ok {
5576            "DNS: resolving correctly\n"
5577        } else {
5578            "DNS: failed\n"
5579        });
5580        if let Ok(o) = Command::new("ip")
5581            .args(["route", "show", "default"])
5582            .output()
5583        {
5584            let text = String::from_utf8_lossy(&o.stdout);
5585            if let Some(line) = text.lines().next() {
5586                out.push_str(&format!("Default gateway: {}\n", line.trim()));
5587            }
5588        }
5589    }
5590
5591    Ok(out.trim_end().to_string())
5592}
5593
5594// ── wifi ──────────────────────────────────────────────────────────────────────
5595
5596fn inspect_wifi() -> Result<String, String> {
5597    let mut out = String::from("Host inspection: wifi\n\n");
5598
5599    #[cfg(target_os = "windows")]
5600    {
5601        let output = Command::new("netsh")
5602            .args(["wlan", "show", "interfaces"])
5603            .output()
5604            .map_err(|e| format!("wifi: {e}"))?;
5605        let text = String::from_utf8_lossy(&output.stdout).to_string();
5606
5607        if text.contains("There is no wireless interface") || text.trim().is_empty() {
5608            out.push_str("No wireless interface detected on this machine.\n");
5609            return Ok(out.trim_end().to_string());
5610        }
5611
5612        let fields = [
5613            ("SSID", "SSID"),
5614            ("State", "State"),
5615            ("Signal", "Signal"),
5616            ("Radio type", "Radio type"),
5617            ("Channel", "Channel"),
5618            ("Receive rate (Mbps)", "Download speed (Mbps)"),
5619            ("Transmit rate (Mbps)", "Upload speed (Mbps)"),
5620            ("Authentication", "Authentication"),
5621            ("Network type", "Network type"),
5622        ];
5623
5624        let mut any = false;
5625        for line in text.lines() {
5626            let trimmed = line.trim();
5627            for (key, label) in &fields {
5628                if trimmed.starts_with(key) && trimmed.contains(':') {
5629                    let val = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim();
5630                    if !val.is_empty() {
5631                        out.push_str(&format!("  {label}: {val}\n"));
5632                        any = true;
5633                    }
5634                }
5635            }
5636        }
5637        if !any {
5638            out.push_str("  (Wi-Fi adapter disconnected or no active connection)\n");
5639        }
5640    }
5641
5642    #[cfg(not(target_os = "windows"))]
5643    {
5644        if let Ok(o) = Command::new("nmcli")
5645            .args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
5646            .output()
5647        {
5648            let text = String::from_utf8_lossy(&o.stdout).to_string();
5649            let lines: Vec<&str> = text.lines().filter(|l| l.contains(":wifi:")).collect();
5650            if lines.is_empty() {
5651                out.push_str("No Wi-Fi devices found.\n");
5652            } else {
5653                for l in lines {
5654                    out.push_str(&format!("  {l}\n"));
5655                }
5656            }
5657        } else if let Ok(o) = Command::new("iwconfig").output() {
5658            let text = String::from_utf8_lossy(&o.stdout).to_string();
5659            if !text.trim().is_empty() {
5660                out.push_str(text.trim());
5661                out.push('\n');
5662            }
5663        } else {
5664            out.push_str("No wireless tool available (install nmcli or wireless-tools).\n");
5665        }
5666    }
5667
5668    Ok(out.trim_end().to_string())
5669}
5670
5671// ── connections ───────────────────────────────────────────────────────────────
5672
5673fn inspect_connections(max_entries: usize) -> Result<String, String> {
5674    let mut out = String::from("Host inspection: connections\n\n");
5675    let n = max_entries.clamp(1, 25);
5676
5677    #[cfg(target_os = "windows")]
5678    {
5679        let script = format!(
5680            r#"
5681try {{
5682    $procs = @{{}}
5683    Get-Process -ErrorAction SilentlyContinue | ForEach-Object {{ $procs[$_.Id] = $_.Name }}
5684    $all = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
5685        Sort-Object OwningProcess
5686    "TOTAL:" + $all.Count
5687    $all | Select-Object -First {n} | ForEach-Object {{
5688        $pname = if ($procs.ContainsKey($_.OwningProcess)) {{ $procs[$_.OwningProcess] }} else {{ "unknown" }}
5689        $pname + "|" + $_.OwningProcess + "|" + $_.LocalAddress + ":" + $_.LocalPort + "|" + $_.RemoteAddress + ":" + $_.RemotePort
5690    }}
5691}} catch {{ "ERROR:" + $_.Exception.Message }}"#
5692        );
5693
5694        let output = Command::new("powershell")
5695            .args(["-NoProfile", "-Command", &script])
5696            .output()
5697            .map_err(|e| format!("connections: {e}"))?;
5698
5699        let raw = String::from_utf8_lossy(&output.stdout);
5700        let text = raw.trim();
5701
5702        if text.starts_with("ERROR:") {
5703            out.push_str(&format!("Unable to query connections: {text}\n"));
5704        } else {
5705            let mut total = 0usize;
5706            let mut rows = Vec::new();
5707            for line in text.lines() {
5708                if let Some(rest) = line.strip_prefix("TOTAL:") {
5709                    total = rest.trim().parse().unwrap_or(0);
5710                } else {
5711                    rows.push(line);
5712                }
5713            }
5714            out.push_str(&format!("Established TCP connections: {total}\n\n"));
5715            for row in &rows {
5716                let parts: Vec<&str> = row.splitn(4, '|').collect();
5717                if parts.len() == 4 {
5718                    out.push_str(&format!("  {:<15} (pid {:<5}) | {} → {}\n", parts[0], parts[1], parts[2], parts[3]));
5719                }
5720            }
5721            if total > n {
5722                out.push_str(&format!(
5723                    "\n  ... {} more connections not shown\n",
5724                    total.saturating_sub(n)
5725                ));
5726            }
5727        }
5728    }
5729
5730    #[cfg(not(target_os = "windows"))]
5731    {
5732        if let Ok(o) = Command::new("ss")
5733            .args(["-tnp", "state", "established"])
5734            .output()
5735        {
5736            let text = String::from_utf8_lossy(&o.stdout);
5737            let lines: Vec<&str> = text
5738                .lines()
5739                .skip(1)
5740                .filter(|l| !l.trim().is_empty())
5741                .collect();
5742            out.push_str(&format!("Established TCP connections: {}\n\n", lines.len()));
5743            for line in lines.iter().take(n) {
5744                out.push_str(&format!("  {}\n", line.trim()));
5745            }
5746            if lines.len() > n {
5747                out.push_str(&format!("\n  ... {} more not shown\n", lines.len() - n));
5748            }
5749        } else {
5750            out.push_str("ss not available — install iproute2\n");
5751        }
5752    }
5753
5754    Ok(out.trim_end().to_string())
5755}
5756
5757// ── vpn ───────────────────────────────────────────────────────────────────────
5758
5759fn inspect_vpn() -> Result<String, String> {
5760    let mut out = String::from("Host inspection: vpn\n\n");
5761
5762    #[cfg(target_os = "windows")]
5763    {
5764        let script = r#"
5765try {
5766    $vpn = Get-NetAdapter -ErrorAction Stop | Where-Object {
5767        $_.InterfaceDescription -match 'VPN|TAP|WireGuard|OpenVPN|Cisco|Palo Alto|GlobalProtect|Juniper|Pulse|NordVPN|ExpressVPN|Mullvad|ProtonVPN' -or
5768        $_.Name -match 'VPN|TAP|WireGuard|tun|ppp|wg\d'
5769    }
5770    if ($vpn) {
5771        foreach ($a in $vpn) {
5772            $a.Name + "|" + $a.InterfaceDescription + "|" + $a.Status + "|" + $a.MediaConnectionState
5773        }
5774    } else { "NONE" }
5775} catch { "ERROR:" + $_.Exception.Message }
5776"#;
5777        let output = Command::new("powershell")
5778            .args(["-NoProfile", "-Command", script])
5779            .output()
5780            .map_err(|e| format!("vpn: {e}"))?;
5781
5782        let raw = String::from_utf8_lossy(&output.stdout);
5783        let text = raw.trim();
5784
5785        if text == "NONE" {
5786            out.push_str("No VPN adapters detected — no active VPN connection found.\n");
5787        } else if text.starts_with("ERROR:") {
5788            out.push_str(&format!("Unable to query adapters: {text}\n"));
5789        } else {
5790            out.push_str("VPN adapters:\n\n");
5791            for line in text.lines() {
5792                let parts: Vec<&str> = line.splitn(4, '|').collect();
5793                if parts.len() >= 3 {
5794                    let name = parts[0];
5795                    let desc = parts[1];
5796                    let status = parts[2];
5797                    let media = parts.get(3).unwrap_or(&"unknown");
5798                    let label = if status.trim() == "Up" {
5799                        "CONNECTED"
5800                    } else {
5801                        "disconnected"
5802                    };
5803                    out.push_str(&format!(
5804                        "  {name} [{label}]\n    {desc}\n    Status: {status} | Media: {media}\n\n"
5805                    ));
5806                }
5807            }
5808        }
5809
5810        // Windows built-in VPN connections
5811        let ras_script = r#"
5812try {
5813    $c = Get-VpnConnection -ErrorAction Stop
5814    if ($c) { foreach ($v in $c) { $v.Name + "|" + $v.ConnectionStatus + "|" + $v.ServerAddress } }
5815    else { "NO_RAS" }
5816} catch { "NO_RAS" }
5817"#;
5818        if let Ok(o) = Command::new("powershell")
5819            .args(["-NoProfile", "-Command", ras_script])
5820            .output()
5821        {
5822            let t = String::from_utf8_lossy(&o.stdout).trim().to_string();
5823            if t != "NO_RAS" && !t.is_empty() {
5824                out.push_str("Windows VPN connections:\n");
5825                for line in t.lines() {
5826                    let parts: Vec<&str> = line.splitn(3, '|').collect();
5827                    if parts.len() >= 2 {
5828                        let name = parts[0];
5829                        let status = parts[1];
5830                        let server = parts.get(2).unwrap_or(&"");
5831                        out.push_str(&format!("  {name} → {server} [{status}]\n"));
5832                    }
5833                }
5834            }
5835        }
5836    }
5837
5838    #[cfg(not(target_os = "windows"))]
5839    {
5840        if let Ok(o) = Command::new("ip").args(["link", "show"]).output() {
5841            let text = String::from_utf8_lossy(&o.stdout);
5842            let vpn_ifaces: Vec<&str> = text
5843                .lines()
5844                .filter(|l| {
5845                    l.contains("tun") || l.contains("tap") || l.contains(" wg") || l.contains("ppp")
5846                })
5847                .collect();
5848            if vpn_ifaces.is_empty() {
5849                out.push_str("No VPN interfaces (tun/tap/wg/ppp) detected.\n");
5850            } else {
5851                out.push_str(&format!("VPN-like interfaces ({}):\n", vpn_ifaces.len()));
5852                for l in vpn_ifaces {
5853                    out.push_str(&format!("  {}\n", l.trim()));
5854                }
5855            }
5856        }
5857    }
5858
5859    Ok(out.trim_end().to_string())
5860}
5861
5862// ── proxy ─────────────────────────────────────────────────────────────────────
5863
5864fn inspect_proxy() -> Result<String, String> {
5865    let mut out = String::from("Host inspection: proxy\n\n");
5866
5867    #[cfg(target_os = "windows")]
5868    {
5869        let script = r#"
5870$ie = Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings' -ErrorAction SilentlyContinue
5871if ($ie) {
5872    "ENABLE:" + $ie.ProxyEnable + "|SERVER:" + $ie.ProxyServer + "|OVERRIDE:" + $ie.ProxyOverride
5873} else { "NONE" }
5874"#;
5875        if let Ok(o) = Command::new("powershell")
5876            .args(["-NoProfile", "-Command", script])
5877            .output()
5878        {
5879            let raw = String::from_utf8_lossy(&o.stdout);
5880            let text = raw.trim();
5881            if text != "NONE" && !text.is_empty() {
5882                let get = |key: &str| -> &str {
5883                    text.split('|')
5884                        .find(|s| s.starts_with(key))
5885                        .and_then(|s| s.splitn(2, ':').nth(1))
5886                        .unwrap_or("")
5887                };
5888                let enabled = get("ENABLE");
5889                let server = get("SERVER");
5890                let overrides = get("OVERRIDE");
5891                out.push_str("WinINET / IE proxy:\n");
5892                out.push_str(&format!(
5893                    "  Enabled: {}\n",
5894                    if enabled == "1" { "yes" } else { "no" }
5895                ));
5896                if !server.is_empty() && server != "None" {
5897                    out.push_str(&format!("  Proxy server: {server}\n"));
5898                }
5899                if !overrides.is_empty() && overrides != "None" {
5900                    out.push_str(&format!("  Bypass list: {overrides}\n"));
5901                }
5902                out.push('\n');
5903            }
5904        }
5905
5906        if let Ok(o) = Command::new("netsh")
5907            .args(["winhttp", "show", "proxy"])
5908            .output()
5909        {
5910            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
5911            out.push_str("WinHTTP proxy:\n");
5912            for line in text.lines() {
5913                let l = line.trim();
5914                if !l.is_empty() {
5915                    out.push_str(&format!("  {l}\n"));
5916                }
5917            }
5918            out.push('\n');
5919        }
5920
5921        let mut env_found = false;
5922        for var in &[
5923            "http_proxy",
5924            "https_proxy",
5925            "HTTP_PROXY",
5926            "HTTPS_PROXY",
5927            "no_proxy",
5928            "NO_PROXY",
5929        ] {
5930            if let Ok(val) = std::env::var(var) {
5931                if !env_found {
5932                    out.push_str("Environment proxy variables:\n");
5933                    env_found = true;
5934                }
5935                out.push_str(&format!("  {var}: {val}\n"));
5936            }
5937        }
5938        if !env_found {
5939            out.push_str("No proxy environment variables set.\n");
5940        }
5941    }
5942
5943    #[cfg(not(target_os = "windows"))]
5944    {
5945        let mut found = false;
5946        for var in &[
5947            "http_proxy",
5948            "https_proxy",
5949            "HTTP_PROXY",
5950            "HTTPS_PROXY",
5951            "no_proxy",
5952            "NO_PROXY",
5953            "ALL_PROXY",
5954            "all_proxy",
5955        ] {
5956            if let Ok(val) = std::env::var(var) {
5957                if !found {
5958                    out.push_str("Proxy environment variables:\n");
5959                    found = true;
5960                }
5961                out.push_str(&format!("  {var}: {val}\n"));
5962            }
5963        }
5964        if !found {
5965            out.push_str("No proxy environment variables set.\n");
5966        }
5967        if let Ok(content) = std::fs::read_to_string("/etc/environment") {
5968            let proxy_lines: Vec<&str> = content
5969                .lines()
5970                .filter(|l| l.to_lowercase().contains("proxy"))
5971                .collect();
5972            if !proxy_lines.is_empty() {
5973                out.push_str("\nSystem proxy (/etc/environment):\n");
5974                for l in proxy_lines {
5975                    out.push_str(&format!("  {l}\n"));
5976                }
5977            }
5978        }
5979    }
5980
5981    Ok(out.trim_end().to_string())
5982}
5983
5984// ── firewall_rules ────────────────────────────────────────────────────────────
5985
5986fn inspect_firewall_rules(max_entries: usize) -> Result<String, String> {
5987    let mut out = String::from("Host inspection: firewall_rules\n\n");
5988    let n = max_entries.clamp(1, 20);
5989
5990    #[cfg(target_os = "windows")]
5991    {
5992        let script = format!(
5993            r#"
5994try {{
5995    $rules = Get-NetFirewallRule -Enabled True -ErrorAction Stop |
5996        Where-Object {{
5997            $_.DisplayGroup -notmatch '^(@|Core Networking|Windows|File and Printer)' -and
5998            $_.Owner -eq $null
5999        }} | Select-Object -First {n} DisplayName, Direction, Action, Profile
6000    "TOTAL:" + $rules.Count
6001    $rules | ForEach-Object {{
6002        $dir = switch ($_.Direction) {{ 1 {{ "Inbound" }}; 2 {{ "Outbound" }}; default {{ "?" }} }}
6003        $act = switch ($_.Action) {{ 2 {{ "Allow" }}; 4 {{ "Block" }}; default {{ "?" }} }}
6004        $_.DisplayName + "|" + $dir + "|" + $act + "|" + $_.Profile
6005    }}
6006}} catch {{ "ERROR:" + $_.Exception.Message }}"#
6007        );
6008
6009        let output = Command::new("powershell")
6010            .args(["-NoProfile", "-Command", &script])
6011            .output()
6012            .map_err(|e| format!("firewall_rules: {e}"))?;
6013
6014        let raw = String::from_utf8_lossy(&output.stdout);
6015        let text = raw.trim();
6016
6017        if text.starts_with("ERROR:") {
6018            out.push_str(&format!(
6019                "Unable to query firewall rules: {}\n",
6020                text.trim_start_matches("ERROR:").trim()
6021            ));
6022            out.push_str("This query may require running as administrator.\n");
6023        } else if text.is_empty() {
6024            out.push_str("No non-default enabled firewall rules found.\n");
6025        } else {
6026            let mut total = 0usize;
6027            for line in text.lines() {
6028                if let Some(rest) = line.strip_prefix("TOTAL:") {
6029                    total = rest.trim().parse().unwrap_or(0);
6030                    out.push_str(&format!(
6031                        "Non-default enabled rules (showing up to {n}):\n\n"
6032                    ));
6033                } else {
6034                    let parts: Vec<&str> = line.splitn(4, '|').collect();
6035                    if parts.len() >= 3 {
6036                        let name = parts[0];
6037                        let dir = parts[1];
6038                        let action = parts[2];
6039                        let profile = parts.get(3).unwrap_or(&"Any");
6040                        let icon = if action == "Block" { "[!]" } else { "   " };
6041                        out.push_str(&format!(
6042                            "  {icon} [{dir}] {action}: {name} (profile: {profile})\n"
6043                        ));
6044                    }
6045                }
6046            }
6047            if total == 0 {
6048                out.push_str("No non-default enabled rules found.\n");
6049            }
6050        }
6051    }
6052
6053    #[cfg(not(target_os = "windows"))]
6054    {
6055        if let Ok(o) = Command::new("ufw").args(["status", "numbered"]).output() {
6056            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6057            if !text.is_empty() {
6058                out.push_str(&text);
6059                out.push('\n');
6060            }
6061        } else if let Ok(o) = Command::new("iptables")
6062            .args(["-L", "-n", "--line-numbers"])
6063            .output()
6064        {
6065            let text = String::from_utf8_lossy(&o.stdout);
6066            for l in text.lines().take(n * 2) {
6067                out.push_str(&format!("  {l}\n"));
6068            }
6069        } else {
6070            out.push_str("ufw and iptables not available or insufficient permissions.\n");
6071        }
6072    }
6073
6074    Ok(out.trim_end().to_string())
6075}
6076
6077// ── traceroute ────────────────────────────────────────────────────────────────
6078
6079fn inspect_traceroute(host: &str, max_entries: usize) -> Result<String, String> {
6080    let mut out = format!("Host inspection: traceroute\n\nTarget: {host}\n\n");
6081    let hops = max_entries.clamp(5, 30);
6082
6083    #[cfg(target_os = "windows")]
6084    {
6085        let output = Command::new("tracert")
6086            .args(["-d", "-h", &hops.to_string(), host])
6087            .output()
6088            .map_err(|e| format!("tracert: {e}"))?;
6089        let raw = String::from_utf8_lossy(&output.stdout);
6090        let mut hop_count = 0usize;
6091        for line in raw.lines() {
6092            let trimmed = line.trim();
6093            if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
6094                hop_count += 1;
6095                out.push_str(&format!("  {trimmed}\n"));
6096            } else if trimmed.starts_with("Tracing") || trimmed.starts_with("Trace complete") {
6097                out.push_str(&format!("{trimmed}\n"));
6098            }
6099        }
6100        if hop_count == 0 {
6101            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6102        }
6103    }
6104
6105    #[cfg(not(target_os = "windows"))]
6106    {
6107        let cmd = if std::path::Path::new("/usr/bin/traceroute").exists()
6108            || std::path::Path::new("/usr/sbin/traceroute").exists()
6109        {
6110            "traceroute"
6111        } else {
6112            "tracepath"
6113        };
6114        let output = Command::new(cmd)
6115            .args(["-m", &hops.to_string(), "-n", host])
6116            .output()
6117            .map_err(|e| format!("{cmd}: {e}"))?;
6118        let raw = String::from_utf8_lossy(&output.stdout);
6119        let mut hop_count = 0usize;
6120        for line in raw.lines().take(hops + 2) {
6121            let trimmed = line.trim();
6122            if !trimmed.is_empty() {
6123                hop_count += 1;
6124                out.push_str(&format!("  {trimmed}\n"));
6125            }
6126        }
6127        if hop_count == 0 {
6128            out.push_str("No hops returned — host may be unreachable or ICMP is blocked.\n");
6129        }
6130    }
6131
6132    Ok(out.trim_end().to_string())
6133}
6134
6135// ── dns_cache ─────────────────────────────────────────────────────────────────
6136
6137fn inspect_dns_cache(max_entries: usize) -> Result<String, String> {
6138    let mut out = String::from("Host inspection: dns_cache\n\n");
6139    let n = max_entries.clamp(10, 100);
6140
6141    #[cfg(target_os = "windows")]
6142    {
6143        let output = Command::new("powershell")
6144            .args([
6145                "-NoProfile",
6146                "-Command",
6147                "Get-DnsClientCache | Select-Object -First 200 Entry,RecordType,Data,TimeToLive | ConvertTo-Csv -NoTypeInformation",
6148            ])
6149            .output()
6150            .map_err(|e| format!("dns_cache: {e}"))?;
6151
6152        let raw = String::from_utf8_lossy(&output.stdout);
6153        let lines: Vec<&str> = raw.lines().skip(1).collect();
6154        let total = lines.len();
6155
6156        if total == 0 {
6157            out.push_str("DNS cache is empty or could not be read.\n");
6158        } else {
6159            out.push_str(&format!(
6160                "DNS cache entries (showing up to {n} of {total}):\n\n"
6161            ));
6162            let mut shown = 0usize;
6163            for line in lines.iter().take(n) {
6164                let cols: Vec<&str> = line.splitn(4, ',').collect();
6165                if cols.len() >= 3 {
6166                    let entry = cols[0].trim_matches('"');
6167                    let rtype = cols[1].trim_matches('"');
6168                    let data = cols[2].trim_matches('"');
6169                    let ttl = cols.get(3).map(|s| s.trim_matches('"')).unwrap_or("?");
6170                    out.push_str(&format!("  {entry:<45} {rtype:<6} {data}  (TTL {ttl}s)\n"));
6171                    shown += 1;
6172                }
6173            }
6174            if total > shown {
6175                out.push_str(&format!("\n  ... and {} more entries\n", total - shown));
6176            }
6177        }
6178    }
6179
6180    #[cfg(not(target_os = "windows"))]
6181    {
6182        if let Ok(o) = Command::new("resolvectl").args(["statistics"]).output() {
6183            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6184            if !text.is_empty() {
6185                out.push_str("systemd-resolved statistics:\n");
6186                for line in text.lines().take(n) {
6187                    out.push_str(&format!("  {line}\n"));
6188                }
6189                out.push('\n');
6190            }
6191        }
6192        if let Ok(o) = Command::new("dscacheutil")
6193            .args(["-cachedump", "-entries", "Host"])
6194            .output()
6195        {
6196            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6197            if !text.is_empty() {
6198                out.push_str("DNS cache (macOS dscacheutil):\n");
6199                for line in text.lines().take(n) {
6200                    out.push_str(&format!("  {line}\n"));
6201                }
6202            } else {
6203                out.push_str("DNS cache is empty or not accessible on this platform.\n");
6204            }
6205        } else {
6206            out.push_str(
6207                "DNS cache inspection not available (no resolvectl or dscacheutil found).\n",
6208            );
6209        }
6210    }
6211
6212    Ok(out.trim_end().to_string())
6213}
6214
6215// ── arp ───────────────────────────────────────────────────────────────────────
6216
6217fn inspect_arp() -> Result<String, String> {
6218    let mut out = String::from("Host inspection: arp\n\n");
6219
6220    #[cfg(target_os = "windows")]
6221    {
6222        let output = Command::new("arp")
6223            .args(["-a"])
6224            .output()
6225            .map_err(|e| format!("arp: {e}"))?;
6226        let raw = String::from_utf8_lossy(&output.stdout);
6227        let mut count = 0usize;
6228        for line in raw.lines() {
6229            let t = line.trim();
6230            if t.is_empty() {
6231                continue;
6232            }
6233            out.push_str(&format!("  {t}\n"));
6234            if t.contains("dynamic") || t.contains("static") {
6235                count += 1;
6236            }
6237        }
6238        out.push_str(&format!("\nTotal entries: {count}\n"));
6239    }
6240
6241    #[cfg(not(target_os = "windows"))]
6242    {
6243        if let Ok(o) = Command::new("arp").args(["-n"]).output() {
6244            let raw = String::from_utf8_lossy(&o.stdout);
6245            let mut count = 0usize;
6246            for line in raw.lines() {
6247                let t = line.trim();
6248                if !t.is_empty() {
6249                    out.push_str(&format!("  {t}\n"));
6250                    count += 1;
6251                }
6252            }
6253            out.push_str(&format!("\nTotal entries: {}\n", count.saturating_sub(1)));
6254        } else if let Ok(o) = Command::new("ip").args(["neigh"]).output() {
6255            let raw = String::from_utf8_lossy(&o.stdout);
6256            let mut count = 0usize;
6257            for line in raw.lines() {
6258                let t = line.trim();
6259                if !t.is_empty() {
6260                    out.push_str(&format!("  {t}\n"));
6261                    count += 1;
6262                }
6263            }
6264            out.push_str(&format!("\nTotal entries: {count}\n"));
6265        } else {
6266            out.push_str("arp and ip neigh not available.\n");
6267        }
6268    }
6269
6270    Ok(out.trim_end().to_string())
6271}
6272
6273// ── route_table ───────────────────────────────────────────────────────────────
6274
6275fn inspect_route_table(max_entries: usize) -> Result<String, String> {
6276    let mut out = String::from("Host inspection: route_table\n\n");
6277    let n = max_entries.clamp(10, 50);
6278
6279    #[cfg(target_os = "windows")]
6280    {
6281        let script = r#"
6282try {
6283    $routes = Get-NetRoute -ErrorAction Stop |
6284        Where-Object { $_.RouteMetric -lt 9000 } |
6285        Sort-Object RouteMetric |
6286        Select-Object DestinationPrefix, NextHop, RouteMetric, InterfaceAlias
6287    "TOTAL:" + $routes.Count
6288    $routes | ForEach-Object {
6289        $_.DestinationPrefix + "|" + $_.NextHop + "|" + $_.RouteMetric + "|" + $_.InterfaceAlias
6290    }
6291} catch { "ERROR:" + $_.Exception.Message }
6292"#;
6293        let output = Command::new("powershell")
6294            .args(["-NoProfile", "-Command", script])
6295            .output()
6296            .map_err(|e| format!("route_table: {e}"))?;
6297        let raw = String::from_utf8_lossy(&output.stdout);
6298        let text = raw.trim();
6299
6300        if text.starts_with("ERROR:") {
6301            out.push_str(&format!(
6302                "Unable to read route table: {}\n",
6303                text.trim_start_matches("ERROR:").trim()
6304            ));
6305        } else {
6306            let mut shown = 0usize;
6307            for line in text.lines() {
6308                if let Some(rest) = line.strip_prefix("TOTAL:") {
6309                    let total: usize = rest.trim().parse().unwrap_or(0);
6310                    out.push_str(&format!(
6311                        "Routing table (showing up to {n} of {total} routes):\n\n"
6312                    ));
6313                    out.push_str(&format!(
6314                        "  {:<22} {:<18} {:>8}  Interface\n",
6315                        "Destination", "Next Hop", "Metric"
6316                    ));
6317                    out.push_str(&format!("  {}\n", "-".repeat(70)));
6318                } else if shown < n {
6319                    let parts: Vec<&str> = line.splitn(4, '|').collect();
6320                    if parts.len() == 4 {
6321                        let dest = parts[0];
6322                        let hop =
6323                            if parts[1].is_empty() || parts[1] == "0.0.0.0" || parts[1] == "::" {
6324                                "on-link"
6325                            } else {
6326                                parts[1]
6327                            };
6328                        let metric = parts[2];
6329                        let iface = parts[3];
6330                        out.push_str(&format!("  {dest:<22} {hop:<18} {metric:>8}  {iface}\n"));
6331                        shown += 1;
6332                    }
6333                }
6334            }
6335        }
6336    }
6337
6338    #[cfg(not(target_os = "windows"))]
6339    {
6340        if let Ok(o) = Command::new("ip").args(["route", "show"]).output() {
6341            let raw = String::from_utf8_lossy(&o.stdout);
6342            let lines: Vec<&str> = raw.lines().collect();
6343            let total = lines.len();
6344            out.push_str(&format!(
6345                "Routing table (showing up to {n} of {total} routes):\n\n"
6346            ));
6347            for line in lines.iter().take(n) {
6348                out.push_str(&format!("  {line}\n"));
6349            }
6350            if total > n {
6351                out.push_str(&format!("\n  ... and {} more routes\n", total - n));
6352            }
6353        } else if let Ok(o) = Command::new("netstat").args(["-rn"]).output() {
6354            let raw = String::from_utf8_lossy(&o.stdout);
6355            for line in raw.lines().take(n) {
6356                out.push_str(&format!("  {line}\n"));
6357            }
6358        } else {
6359            out.push_str("ip route and netstat not available.\n");
6360        }
6361    }
6362
6363    Ok(out.trim_end().to_string())
6364}
6365
6366// ── env ───────────────────────────────────────────────────────────────────────
6367
6368fn inspect_env(max_entries: usize) -> Result<String, String> {
6369    let mut out = String::from("Host inspection: env\n\n");
6370    let n = max_entries.clamp(10, 50);
6371
6372    fn looks_like_secret(name: &str) -> bool {
6373        let n = name.to_uppercase();
6374        n.contains("KEY")
6375            || n.contains("SECRET")
6376            || n.contains("TOKEN")
6377            || n.contains("PASSWORD")
6378            || n.contains("PASSWD")
6379            || n.contains("CREDENTIAL")
6380            || n.contains("AUTH")
6381            || n.contains("CERT")
6382            || n.contains("PRIVATE")
6383    }
6384
6385    let known_dev_vars: &[&str] = &[
6386        "CARGO_HOME",
6387        "RUSTUP_HOME",
6388        "GOPATH",
6389        "GOROOT",
6390        "GOBIN",
6391        "JAVA_HOME",
6392        "ANDROID_HOME",
6393        "ANDROID_SDK_ROOT",
6394        "PYTHONPATH",
6395        "PYTHONHOME",
6396        "VIRTUAL_ENV",
6397        "CONDA_DEFAULT_ENV",
6398        "CONDA_PREFIX",
6399        "NODE_PATH",
6400        "NVM_DIR",
6401        "NVM_BIN",
6402        "PNPM_HOME",
6403        "DENO_INSTALL",
6404        "DENO_DIR",
6405        "DOTNET_ROOT",
6406        "NUGET_PACKAGES",
6407        "CMAKE_HOME",
6408        "VCPKG_ROOT",
6409        "AWS_PROFILE",
6410        "AWS_REGION",
6411        "AWS_DEFAULT_REGION",
6412        "GCP_PROJECT",
6413        "GOOGLE_CLOUD_PROJECT",
6414        "GOOGLE_APPLICATION_CREDENTIALS",
6415        "AZURE_SUBSCRIPTION_ID",
6416        "DATABASE_URL",
6417        "REDIS_URL",
6418        "MONGO_URI",
6419        "EDITOR",
6420        "VISUAL",
6421        "SHELL",
6422        "TERM",
6423        "XDG_CONFIG_HOME",
6424        "XDG_DATA_HOME",
6425        "XDG_CACHE_HOME",
6426        "HOME",
6427        "USERPROFILE",
6428        "APPDATA",
6429        "LOCALAPPDATA",
6430        "TEMP",
6431        "TMP",
6432        "COMPUTERNAME",
6433        "USERNAME",
6434        "USERDOMAIN",
6435        "PROCESSOR_ARCHITECTURE",
6436        "NUMBER_OF_PROCESSORS",
6437        "OS",
6438        "HOMEDRIVE",
6439        "HOMEPATH",
6440        "HTTP_PROXY",
6441        "HTTPS_PROXY",
6442        "NO_PROXY",
6443        "ALL_PROXY",
6444        "http_proxy",
6445        "https_proxy",
6446        "no_proxy",
6447        "DOCKER_HOST",
6448        "DOCKER_BUILDKIT",
6449        "COMPOSE_PROJECT_NAME",
6450        "KUBECONFIG",
6451        "KUBE_CONTEXT",
6452        "CI",
6453        "GITHUB_ACTIONS",
6454        "GITLAB_CI",
6455        "LMSTUDIO_HOME",
6456        "HEMATITE_URL",
6457    ];
6458
6459    let mut all_vars: Vec<(String, String)> = std::env::vars().collect();
6460    all_vars.sort_by(|a, b| a.0.cmp(&b.0));
6461    let total = all_vars.len();
6462
6463    let mut dev_found: Vec<String> = Vec::new();
6464    let mut secret_found: Vec<String> = Vec::new();
6465
6466    for (k, v) in &all_vars {
6467        if k == "PATH" {
6468            continue;
6469        }
6470        if looks_like_secret(k) {
6471            secret_found.push(format!("{k} = [SET, {} chars]", v.len()));
6472        } else {
6473            let k_upper = k.to_uppercase();
6474            let is_known = known_dev_vars
6475                .iter()
6476                .any(|kv| k_upper.as_str() == kv.to_uppercase().as_str());
6477            if is_known {
6478                let display = if v.len() > 120 {
6479                    format!("{k} = {}…", &v[..117])
6480                } else {
6481                    format!("{k} = {v}")
6482                };
6483                dev_found.push(display);
6484            }
6485        }
6486    }
6487
6488    out.push_str(&format!("Total environment variables: {total}\n\n"));
6489
6490    if let Ok(p) = std::env::var("PATH") {
6491        let sep = if cfg!(target_os = "windows") {
6492            ';'
6493        } else {
6494            ':'
6495        };
6496        let count = p.split(sep).count();
6497        out.push_str(&format!(
6498            "PATH: {count} entries (use topic=path for full audit)\n\n"
6499        ));
6500    }
6501
6502    if !secret_found.is_empty() {
6503        out.push_str(&format!(
6504            "=== Secret/credential variables ({} detected, values hidden) ===\n",
6505            secret_found.len()
6506        ));
6507        for s in secret_found.iter().take(n) {
6508            out.push_str(&format!("  {s}\n"));
6509        }
6510        out.push('\n');
6511    }
6512
6513    if !dev_found.is_empty() {
6514        out.push_str(&format!(
6515            "=== Developer & tool variables ({}) ===\n",
6516            dev_found.len()
6517        ));
6518        for d in dev_found.iter().take(n) {
6519            out.push_str(&format!("  {d}\n"));
6520        }
6521        out.push('\n');
6522    }
6523
6524    let other_count = all_vars
6525        .iter()
6526        .filter(|(k, _)| {
6527            k != "PATH"
6528                && !looks_like_secret(k)
6529                && !known_dev_vars
6530                    .iter()
6531                    .any(|kv| k.to_uppercase().as_str() == kv.to_uppercase().as_str())
6532        })
6533        .count();
6534    if other_count > 0 {
6535        out.push_str(&format!(
6536            "Other variables: {other_count} (use 'env' in shell to see all)\n"
6537        ));
6538    }
6539
6540    Ok(out.trim_end().to_string())
6541}
6542
6543// ── hosts_file ────────────────────────────────────────────────────────────────
6544
6545fn inspect_hosts_file() -> Result<String, String> {
6546    let mut out = String::from("Host inspection: hosts_file\n\n");
6547
6548    let hosts_path = if cfg!(target_os = "windows") {
6549        std::path::PathBuf::from(r"C:\Windows\System32\drivers\etc\hosts")
6550    } else {
6551        std::path::PathBuf::from("/etc/hosts")
6552    };
6553
6554    out.push_str(&format!("Path: {}\n\n", hosts_path.display()));
6555
6556    match fs::read_to_string(&hosts_path) {
6557        Ok(content) => {
6558            let mut active_entries: Vec<String> = Vec::new();
6559            let mut comment_lines = 0usize;
6560            let mut blank_lines = 0usize;
6561
6562            for line in content.lines() {
6563                let t = line.trim();
6564                if t.is_empty() {
6565                    blank_lines += 1;
6566                } else if t.starts_with('#') {
6567                    comment_lines += 1;
6568                } else {
6569                    active_entries.push(line.to_string());
6570                }
6571            }
6572
6573            out.push_str(&format!(
6574                "Active entries: {}  |  Comment lines: {}  |  Blank lines: {}\n\n",
6575                active_entries.len(),
6576                comment_lines,
6577                blank_lines
6578            ));
6579
6580            if active_entries.is_empty() {
6581                out.push_str(
6582                    "No active host entries (file contains only comments/blanks — standard default state).\n",
6583                );
6584            } else {
6585                out.push_str("=== Active entries ===\n");
6586                for entry in &active_entries {
6587                    out.push_str(&format!("  {entry}\n"));
6588                }
6589                out.push('\n');
6590
6591                let custom: Vec<&String> = active_entries
6592                    .iter()
6593                    .filter(|e| {
6594                        let t = e.trim_start();
6595                        !t.starts_with("127.") && !t.starts_with("::1") && !t.starts_with("0.0.0.0")
6596                    })
6597                    .collect();
6598                if !custom.is_empty() {
6599                    out.push_str(&format!(
6600                        "[!] Custom (non-loopback) entries: {}\n",
6601                        custom.len()
6602                    ));
6603                    for e in &custom {
6604                        out.push_str(&format!("  {e}\n"));
6605                    }
6606                } else {
6607                    out.push_str("All active entries are standard loopback or block entries.\n");
6608                }
6609            }
6610
6611            out.push_str("\n=== Full file ===\n");
6612            for line in content.lines() {
6613                out.push_str(&format!("  {line}\n"));
6614            }
6615        }
6616        Err(e) => {
6617            out.push_str(&format!("Could not read hosts file: {e}\n"));
6618            if cfg!(target_os = "windows") {
6619                out.push_str(
6620                    "On Windows, run Hematite as Administrator if permission is denied.\n",
6621                );
6622            }
6623        }
6624    }
6625
6626    Ok(out.trim_end().to_string())
6627}
6628
6629// ── docker ────────────────────────────────────────────────────────────────────
6630
6631fn inspect_docker(max_entries: usize) -> Result<String, String> {
6632    let mut out = String::from("Host inspection: docker\n\n");
6633    let n = max_entries.clamp(5, 25);
6634
6635    let version_output = Command::new("docker")
6636        .args(["version", "--format", "{{.Server.Version}}"])
6637        .output();
6638
6639    match version_output {
6640        Err(_) => {
6641            out.push_str("Docker: not found on PATH.\n");
6642            out.push_str(
6643                "Install Docker Desktop: https://www.docker.com/products/docker-desktop\n",
6644            );
6645            return Ok(out.trim_end().to_string());
6646        }
6647        Ok(o) if !o.status.success() => {
6648            let stderr = String::from_utf8_lossy(&o.stderr);
6649            if stderr.contains("cannot connect")
6650                || stderr.contains("Is the docker daemon running")
6651                || stderr.contains("pipe")
6652                || stderr.contains("socket")
6653            {
6654                out.push_str("Docker: installed but daemon is NOT running.\n");
6655                out.push_str("Start Docker Desktop or run: sudo systemctl start docker\n");
6656            } else {
6657                out.push_str(&format!("Docker: error — {}\n", stderr.trim()));
6658            }
6659            return Ok(out.trim_end().to_string());
6660        }
6661        Ok(o) => {
6662            let version = String::from_utf8_lossy(&o.stdout).trim().to_string();
6663            out.push_str(&format!("Docker Engine: {version}\n"));
6664        }
6665    }
6666
6667    if let Ok(o) = Command::new("docker")
6668        .args([
6669            "info",
6670            "--format",
6671            "Containers: {{.Containers}} (running: {{.ContainersRunning}}, stopped: {{.ContainersStopped}})\nImages: {{.Images}}\nStorage driver: {{.Driver}}\nOS/Arch: {{.OSType}}/{{.Architecture}}\nCPUs: {{.NCPU}}",
6672        ])
6673        .output()
6674    {
6675        let info = String::from_utf8_lossy(&o.stdout);
6676        for line in info.lines() {
6677            let t = line.trim();
6678            if !t.is_empty() {
6679                out.push_str(&format!("  {t}\n"));
6680            }
6681        }
6682        out.push('\n');
6683    }
6684
6685    if let Ok(o) = Command::new("docker")
6686        .args([
6687            "ps",
6688            "--format",
6689            "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}",
6690        ])
6691        .output()
6692    {
6693        let raw = String::from_utf8_lossy(&o.stdout);
6694        let lines: Vec<&str> = raw.lines().collect();
6695        if lines.len() <= 1 {
6696            out.push_str("Running containers: none\n\n");
6697        } else {
6698            out.push_str(&format!(
6699                "=== Running containers ({}) ===\n",
6700                lines.len().saturating_sub(1)
6701            ));
6702            for line in lines.iter().take(n + 1) {
6703                out.push_str(&format!("  {line}\n"));
6704            }
6705            if lines.len() > n + 1 {
6706                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
6707            }
6708            out.push('\n');
6709        }
6710    }
6711
6712    if let Ok(o) = Command::new("docker")
6713        .args([
6714            "images",
6715            "--format",
6716            "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
6717        ])
6718        .output()
6719    {
6720        let raw = String::from_utf8_lossy(&o.stdout);
6721        let lines: Vec<&str> = raw.lines().collect();
6722        if lines.len() > 1 {
6723            out.push_str(&format!(
6724                "=== Local images ({}) ===\n",
6725                lines.len().saturating_sub(1)
6726            ));
6727            for line in lines.iter().take(n + 1) {
6728                out.push_str(&format!("  {line}\n"));
6729            }
6730            if lines.len() > n + 1 {
6731                out.push_str(&format!("  ... and {} more\n", lines.len() - n - 1));
6732            }
6733            out.push('\n');
6734        }
6735    }
6736
6737    if let Ok(o) = Command::new("docker")
6738        .args([
6739            "compose",
6740            "ls",
6741            "--format",
6742            "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}",
6743        ])
6744        .output()
6745    {
6746        let raw = String::from_utf8_lossy(&o.stdout);
6747        let lines: Vec<&str> = raw.lines().collect();
6748        if lines.len() > 1 {
6749            out.push_str(&format!(
6750                "=== Compose projects ({}) ===\n",
6751                lines.len().saturating_sub(1)
6752            ));
6753            for line in lines.iter().take(n + 1) {
6754                out.push_str(&format!("  {line}\n"));
6755            }
6756            out.push('\n');
6757        }
6758    }
6759
6760    if let Ok(o) = Command::new("docker").args(["context", "show"]).output() {
6761        let ctx = String::from_utf8_lossy(&o.stdout).trim().to_string();
6762        if !ctx.is_empty() {
6763            out.push_str(&format!("Active context: {ctx}\n"));
6764        }
6765    }
6766
6767    Ok(out.trim_end().to_string())
6768}
6769
6770// ── wsl ───────────────────────────────────────────────────────────────────────
6771
6772fn inspect_wsl() -> Result<String, String> {
6773    let mut out = String::from("Host inspection: wsl\n\n");
6774
6775    #[cfg(target_os = "windows")]
6776    {
6777        if let Ok(o) = Command::new("wsl").args(["--version"]).output() {
6778            let raw = String::from_utf8_lossy(&o.stdout);
6779            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6780            for line in cleaned.lines().take(4) {
6781                let t = line.trim();
6782                if !t.is_empty() {
6783                    out.push_str(&format!("  {t}\n"));
6784                }
6785            }
6786            out.push('\n');
6787        }
6788
6789        let list_output = Command::new("wsl").args(["--list", "--verbose"]).output();
6790        match list_output {
6791            Err(e) => {
6792                out.push_str(&format!("WSL: wsl.exe error: {e}\n"));
6793                out.push_str("WSL may not be installed. Enable with: wsl --install\n");
6794            }
6795            Ok(o) if !o.status.success() => {
6796                let stderr = String::from_utf8_lossy(&o.stderr);
6797                let cleaned: String = stderr.chars().filter(|c| *c != '\0').collect();
6798                out.push_str(&format!("WSL: error — {}\n", cleaned.trim()));
6799                out.push_str("Run: wsl --install\n");
6800            }
6801            Ok(o) => {
6802                let raw = String::from_utf8_lossy(&o.stdout);
6803                let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6804                let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
6805                let distro_lines: Vec<&str> = lines
6806                    .iter()
6807                    .filter(|l| {
6808                        let t = l.trim();
6809                        !t.is_empty()
6810                            && !t.to_uppercase().starts_with("NAME")
6811                            && !t.starts_with("---")
6812                    })
6813                    .copied()
6814                    .collect();
6815
6816                if distro_lines.is_empty() {
6817                    out.push_str("WSL: installed but no distributions found.\n");
6818                    out.push_str("Install a distro: wsl --install -d Ubuntu\n");
6819                } else {
6820                    out.push_str("=== WSL Distributions ===\n");
6821                    for line in &lines {
6822                        out.push_str(&format!("  {}\n", line.trim()));
6823                    }
6824                    out.push_str(&format!("\nTotal distributions: {}\n", distro_lines.len()));
6825                }
6826            }
6827        }
6828
6829        if let Ok(o) = Command::new("wsl").args(["--status"]).output() {
6830            let raw = String::from_utf8_lossy(&o.stdout);
6831            let cleaned: String = raw.chars().filter(|c| *c != '\0').collect();
6832            let status_lines: Vec<&str> = cleaned
6833                .lines()
6834                .filter(|l| !l.trim().is_empty())
6835                .take(8)
6836                .collect();
6837            if !status_lines.is_empty() {
6838                out.push_str("\n=== WSL status ===\n");
6839                for line in status_lines {
6840                    out.push_str(&format!("  {}\n", line.trim()));
6841                }
6842            }
6843        }
6844    }
6845
6846    #[cfg(not(target_os = "windows"))]
6847    {
6848        out.push_str("WSL (Windows Subsystem for Linux) is a Windows-only feature.\n");
6849        out.push_str("On Linux/macOS, use native virtualization (KVM, UTM, Parallels) instead.\n");
6850    }
6851
6852    Ok(out.trim_end().to_string())
6853}
6854
6855// ── ssh ───────────────────────────────────────────────────────────────────────
6856
6857fn dirs_home() -> Option<PathBuf> {
6858    std::env::var("HOME")
6859        .ok()
6860        .map(PathBuf::from)
6861        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
6862}
6863
6864fn inspect_ssh() -> Result<String, String> {
6865    let mut out = String::from("Host inspection: ssh\n\n");
6866
6867    if let Ok(o) = Command::new("ssh").args(["-V"]).output() {
6868        let ver = if o.stdout.is_empty() {
6869            String::from_utf8_lossy(&o.stderr).trim().to_string()
6870        } else {
6871            String::from_utf8_lossy(&o.stdout).trim().to_string()
6872        };
6873        if !ver.is_empty() {
6874            out.push_str(&format!("SSH client: {ver}\n"));
6875        }
6876    } else {
6877        out.push_str("SSH client: not found on PATH.\n");
6878    }
6879
6880    #[cfg(target_os = "windows")]
6881    {
6882        let script = r#"
6883$svc = Get-Service -Name sshd -ErrorAction SilentlyContinue
6884if ($svc) { "SSHD:" + $svc.Status + " | StartType:" + $svc.StartType }
6885else { "SSHD:not_installed" }
6886"#;
6887        if let Ok(o) = Command::new("powershell")
6888            .args(["-NoProfile", "-Command", script])
6889            .output()
6890        {
6891            let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
6892            if text.contains("not_installed") {
6893                out.push_str("SSH server (sshd): not installed\n");
6894            } else {
6895                out.push_str(&format!(
6896                    "SSH server (sshd): {}\n",
6897                    text.trim_start_matches("SSHD:")
6898                ));
6899            }
6900        }
6901    }
6902
6903    #[cfg(not(target_os = "windows"))]
6904    {
6905        if let Ok(o) = Command::new("systemctl")
6906            .args(["is-active", "sshd"])
6907            .output()
6908        {
6909            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
6910            out.push_str(&format!("SSH server (sshd): {status}\n"));
6911        } else if let Ok(o) = Command::new("systemctl")
6912            .args(["is-active", "ssh"])
6913            .output()
6914        {
6915            let status = String::from_utf8_lossy(&o.stdout).trim().to_string();
6916            out.push_str(&format!("SSH server (ssh): {status}\n"));
6917        }
6918    }
6919
6920    out.push('\n');
6921
6922    if let Some(ssh_dir) = dirs_home().map(|h| h.join(".ssh")) {
6923        if ssh_dir.exists() {
6924            out.push_str(&format!("~/.ssh: {}\n", ssh_dir.display()));
6925
6926            let kh = ssh_dir.join("known_hosts");
6927            if kh.exists() {
6928                let count = fs::read_to_string(&kh)
6929                    .map(|c| {
6930                        c.lines()
6931                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
6932                            .count()
6933                    })
6934                    .unwrap_or(0);
6935                out.push_str(&format!("  known_hosts: {count} entries\n"));
6936            } else {
6937                out.push_str("  known_hosts: not present\n");
6938            }
6939
6940            let ak = ssh_dir.join("authorized_keys");
6941            if ak.exists() {
6942                let count = fs::read_to_string(&ak)
6943                    .map(|c| {
6944                        c.lines()
6945                            .filter(|l| !l.trim().is_empty() && !l.trim().starts_with('#'))
6946                            .count()
6947                    })
6948                    .unwrap_or(0);
6949                out.push_str(&format!("  authorized_keys: {count} public keys\n"));
6950            } else {
6951                out.push_str("  authorized_keys: not present\n");
6952            }
6953
6954            let key_names = [
6955                "id_rsa",
6956                "id_ed25519",
6957                "id_ecdsa",
6958                "id_dsa",
6959                "id_ecdsa_sk",
6960                "id_ed25519_sk",
6961            ];
6962            let found_keys: Vec<&str> = key_names
6963                .iter()
6964                .filter(|k| ssh_dir.join(k).exists())
6965                .copied()
6966                .collect();
6967            if !found_keys.is_empty() {
6968                out.push_str(&format!("  Private keys: {}\n", found_keys.join(", ")));
6969            } else {
6970                out.push_str("  Private keys: none found\n");
6971            }
6972
6973            let config_path = ssh_dir.join("config");
6974            if config_path.exists() {
6975                out.push_str("\n=== SSH config hosts ===\n");
6976                match fs::read_to_string(&config_path) {
6977                    Ok(content) => {
6978                        let mut hosts: Vec<(String, Vec<String>)> = Vec::new();
6979                        let mut current: Option<(String, Vec<String>)> = None;
6980                        for line in content.lines() {
6981                            let t = line.trim();
6982                            if t.is_empty() || t.starts_with('#') {
6983                                continue;
6984                            }
6985                            if let Some(host) = t.strip_prefix("Host ") {
6986                                if let Some(prev) = current.take() {
6987                                    hosts.push(prev);
6988                                }
6989                                current = Some((host.trim().to_string(), Vec::new()));
6990                            } else if let Some((_, ref mut details)) = current {
6991                                let tu = t.to_uppercase();
6992                                if tu.starts_with("HOSTNAME ")
6993                                    || tu.starts_with("USER ")
6994                                    || tu.starts_with("PORT ")
6995                                    || tu.starts_with("IDENTITYFILE ")
6996                                {
6997                                    details.push(t.to_string());
6998                                }
6999                            }
7000                        }
7001                        if let Some(prev) = current {
7002                            hosts.push(prev);
7003                        }
7004
7005                        if hosts.is_empty() {
7006                            out.push_str("  No Host entries found.\n");
7007                        } else {
7008                            for (h, details) in &hosts {
7009                                if details.is_empty() {
7010                                    out.push_str(&format!("  Host {h}\n"));
7011                                } else {
7012                                    out.push_str(&format!(
7013                                        "  Host {h}  [{}]\n",
7014                                        details.join(", ")
7015                                    ));
7016                                }
7017                            }
7018                            out.push_str(&format!("\n  Total configured hosts: {}\n", hosts.len()));
7019                        }
7020                    }
7021                    Err(e) => out.push_str(&format!("  Could not read config: {e}\n")),
7022                }
7023            } else {
7024                out.push_str("  SSH config: not present\n");
7025            }
7026        } else {
7027            out.push_str("~/.ssh: directory not found (no SSH keys configured).\n");
7028        }
7029    }
7030
7031    Ok(out.trim_end().to_string())
7032}
7033
7034// ── installed_software ────────────────────────────────────────────────────────
7035
7036fn inspect_installed_software(max_entries: usize) -> Result<String, String> {
7037    let mut out = String::from("Host inspection: installed_software\n\n");
7038    let n = max_entries.clamp(10, 50);
7039
7040    #[cfg(target_os = "windows")]
7041    {
7042        let winget_out = Command::new("winget")
7043            .args(["list", "--accept-source-agreements"])
7044            .output();
7045
7046        if let Ok(o) = winget_out {
7047            if o.status.success() {
7048                let raw = String::from_utf8_lossy(&o.stdout);
7049                let mut header_done = false;
7050                let mut packages: Vec<&str> = Vec::new();
7051                for line in raw.lines() {
7052                    let t = line.trim();
7053                    if t.starts_with("---") {
7054                        header_done = true;
7055                        continue;
7056                    }
7057                    if header_done && !t.is_empty() {
7058                        packages.push(line);
7059                    }
7060                }
7061                let total = packages.len();
7062                out.push_str(&format!(
7063                    "=== Installed software via winget ({total} packages) ===\n\n"
7064                ));
7065                for line in packages.iter().take(n) {
7066                    out.push_str(&format!("  {line}\n"));
7067                }
7068                if total > n {
7069                    out.push_str(&format!("\n  ... and {} more packages\n", total - n));
7070                }
7071                out.push_str("\nFor full list: winget list\n");
7072                return Ok(out.trim_end().to_string());
7073            }
7074        }
7075
7076        // Fallback: registry scan
7077        let script = format!(
7078            r#"
7079$apps = @()
7080$reg_paths = @(
7081    'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
7082    'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
7083    'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
7084)
7085foreach ($p in $reg_paths) {{
7086    try {{
7087        $apps += Get-ItemProperty $p -ErrorAction SilentlyContinue |
7088            Where-Object {{ $_.DisplayName }} |
7089            Select-Object DisplayName, DisplayVersion, Publisher
7090    }} catch {{}}
7091}}
7092$sorted = $apps | Sort-Object DisplayName -Unique
7093"TOTAL:" + $sorted.Count
7094$sorted | Select-Object -First {n} | ForEach-Object {{
7095    $_.DisplayName + "|" + $_.DisplayVersion + "|" + $_.Publisher
7096}}
7097"#
7098        );
7099        if let Ok(o) = Command::new("powershell")
7100            .args(["-NoProfile", "-Command", &script])
7101            .output()
7102        {
7103            let raw = String::from_utf8_lossy(&o.stdout);
7104            out.push_str("=== Installed software (registry scan) ===\n");
7105            out.push_str(&format!("  {:<50} {:<18} Publisher\n", "Name", "Version"));
7106            out.push_str(&format!("  {}\n", "-".repeat(90)));
7107            for line in raw.lines() {
7108                if let Some(rest) = line.strip_prefix("TOTAL:") {
7109                    let total: usize = rest.trim().parse().unwrap_or(0);
7110                    out.push_str(&format!("  (Total: {total}, showing first {n})\n\n"));
7111                } else if !line.trim().is_empty() {
7112                    let parts: Vec<&str> = line.splitn(3, '|').collect();
7113                    let name = parts.first().map(|s| s.trim()).unwrap_or("");
7114                    let ver = parts.get(1).map(|s| s.trim()).unwrap_or("");
7115                    let pub_ = parts.get(2).map(|s| s.trim()).unwrap_or("");
7116                    out.push_str(&format!("  {:<50} {:<18} {pub_}\n", name, ver));
7117                }
7118            }
7119        } else {
7120            out.push_str(
7121                "Could not query installed software (winget and registry scan both failed).\n",
7122            );
7123        }
7124    }
7125
7126    #[cfg(target_os = "linux")]
7127    {
7128        let mut found = false;
7129        if let Ok(o) = Command::new("dpkg").args(["--get-selections"]).output() {
7130            if o.status.success() {
7131                let raw = String::from_utf8_lossy(&o.stdout);
7132                let installed: Vec<&str> = raw.lines().filter(|l| l.contains("install")).collect();
7133                let total = installed.len();
7134                out.push_str(&format!("=== Installed packages via dpkg ({total}) ===\n"));
7135                for line in installed.iter().take(n) {
7136                    out.push_str(&format!("  {}\n", line.trim()));
7137                }
7138                if total > n {
7139                    out.push_str(&format!("  ... and {} more\n", total - n));
7140                }
7141                out.push_str("\nFor full list: dpkg --get-selections | grep install\n");
7142                found = true;
7143            }
7144        }
7145        if !found {
7146            if let Ok(o) = Command::new("rpm")
7147                .args(["-qa", "--queryformat", "%{NAME} %{VERSION}\n"])
7148                .output()
7149            {
7150                if o.status.success() {
7151                    let raw = String::from_utf8_lossy(&o.stdout);
7152                    let lines: Vec<&str> = raw.lines().collect();
7153                    let total = lines.len();
7154                    out.push_str(&format!("=== Installed packages via rpm ({total}) ===\n"));
7155                    for line in lines.iter().take(n) {
7156                        out.push_str(&format!("  {line}\n"));
7157                    }
7158                    if total > n {
7159                        out.push_str(&format!("  ... and {} more\n", total - n));
7160                    }
7161                    found = true;
7162                }
7163            }
7164        }
7165        if !found {
7166            if let Ok(o) = Command::new("pacman").args(["-Q"]).output() {
7167                if o.status.success() {
7168                    let raw = String::from_utf8_lossy(&o.stdout);
7169                    let lines: Vec<&str> = raw.lines().collect();
7170                    let total = lines.len();
7171                    out.push_str(&format!(
7172                        "=== Installed packages via pacman ({total}) ===\n"
7173                    ));
7174                    for line in lines.iter().take(n) {
7175                        out.push_str(&format!("  {line}\n"));
7176                    }
7177                    if total > n {
7178                        out.push_str(&format!("  ... and {} more\n", total - n));
7179                    }
7180                    found = true;
7181                }
7182            }
7183        }
7184        if !found {
7185            out.push_str("No package manager found (tried dpkg, rpm, pacman).\n");
7186        }
7187    }
7188
7189    #[cfg(target_os = "macos")]
7190    {
7191        if let Ok(o) = Command::new("brew").args(["list", "--versions"]).output() {
7192            if o.status.success() {
7193                let raw = String::from_utf8_lossy(&o.stdout);
7194                let lines: Vec<&str> = raw.lines().collect();
7195                let total = lines.len();
7196                out.push_str(&format!("=== Homebrew packages ({total}) ===\n"));
7197                for line in lines.iter().take(n) {
7198                    out.push_str(&format!("  {line}\n"));
7199                }
7200                if total > n {
7201                    out.push_str(&format!("  ... and {} more\n", total - n));
7202                }
7203                out.push_str("\nFor full list: brew list --versions\n");
7204            }
7205        } else {
7206            out.push_str("Homebrew not found.\n");
7207        }
7208        if let Ok(o) = Command::new("mas").args(["list"]).output() {
7209            if o.status.success() {
7210                let raw = String::from_utf8_lossy(&o.stdout);
7211                let lines: Vec<&str> = raw.lines().collect();
7212                out.push_str(&format!("\n=== Mac App Store apps ({}) ===\n", lines.len()));
7213                for line in lines.iter().take(n) {
7214                    out.push_str(&format!("  {line}\n"));
7215                }
7216            }
7217        }
7218    }
7219
7220    Ok(out.trim_end().to_string())
7221}
7222
7223// ── git_config ────────────────────────────────────────────────────────────────
7224
7225fn inspect_git_config() -> Result<String, String> {
7226    let mut out = String::from("Host inspection: git_config\n\n");
7227
7228    if let Ok(o) = Command::new("git").args(["--version"]).output() {
7229        let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
7230        out.push_str(&format!("Git: {ver}\n\n"));
7231    } else {
7232        out.push_str("Git: not found on PATH.\n");
7233        return Ok(out.trim_end().to_string());
7234    }
7235
7236    if let Ok(o) = Command::new("git")
7237        .args(["config", "--global", "--list"])
7238        .output()
7239    {
7240        if o.status.success() {
7241            let raw = String::from_utf8_lossy(&o.stdout);
7242            let mut pairs: Vec<(String, String)> = raw
7243                .lines()
7244                .filter_map(|l| {
7245                    let mut parts = l.splitn(2, '=');
7246                    let k = parts.next()?.trim().to_string();
7247                    let v = parts.next().unwrap_or("").trim().to_string();
7248                    Some((k, v))
7249                })
7250                .collect();
7251            pairs.sort_by(|a, b| a.0.cmp(&b.0));
7252
7253            out.push_str("=== Global git config ===\n");
7254
7255            let sections: &[(&str, &[&str])] = &[
7256                ("Identity", &["user.name", "user.email", "user.signingkey"]),
7257                (
7258                    "Core",
7259                    &[
7260                        "core.editor",
7261                        "core.autocrlf",
7262                        "core.eol",
7263                        "core.ignorecase",
7264                        "core.filemode",
7265                    ],
7266                ),
7267                (
7268                    "Commit/Signing",
7269                    &[
7270                        "commit.gpgsign",
7271                        "tag.gpgsign",
7272                        "gpg.format",
7273                        "gpg.ssh.allowedsignersfile",
7274                    ],
7275                ),
7276                (
7277                    "Push/Pull",
7278                    &[
7279                        "push.default",
7280                        "push.autosetupremote",
7281                        "pull.rebase",
7282                        "pull.ff",
7283                    ],
7284                ),
7285                ("Credential", &["credential.helper"]),
7286                ("Branch", &["init.defaultbranch", "branch.autosetuprebase"]),
7287            ];
7288
7289            let mut shown_keys: HashSet<String> = HashSet::new();
7290            for (section, keys) in sections {
7291                let mut section_lines: Vec<String> = Vec::new();
7292                for key in *keys {
7293                    if let Some((k, v)) = pairs.iter().find(|(kk, _)| kk == key) {
7294                        section_lines.push(format!("  {k} = {v}"));
7295                        shown_keys.insert(k.clone());
7296                    }
7297                }
7298                if !section_lines.is_empty() {
7299                    out.push_str(&format!("\n[{section}]\n"));
7300                    for line in section_lines {
7301                        out.push_str(&format!("{line}\n"));
7302                    }
7303                }
7304            }
7305
7306            let other: Vec<&(String, String)> = pairs
7307                .iter()
7308                .filter(|(k, _)| !shown_keys.contains(k) && !k.starts_with("alias."))
7309                .collect();
7310            if !other.is_empty() {
7311                out.push_str("\n[Other]\n");
7312                for (k, v) in other.iter().take(20) {
7313                    out.push_str(&format!("  {k} = {v}\n"));
7314                }
7315                if other.len() > 20 {
7316                    out.push_str(&format!("  ... and {} more\n", other.len() - 20));
7317                }
7318            }
7319
7320            out.push_str(&format!("\nTotal global config keys: {}\n", pairs.len()));
7321        } else {
7322            out.push_str("No global git config found.\n");
7323            out.push_str("Set up with:\n");
7324            out.push_str("  git config --global user.name \"Your Name\"\n");
7325            out.push_str("  git config --global user.email \"you@example.com\"\n");
7326        }
7327    }
7328
7329    if let Ok(o) = Command::new("git")
7330        .args(["config", "--local", "--list"])
7331        .output()
7332    {
7333        if o.status.success() {
7334            let raw = String::from_utf8_lossy(&o.stdout);
7335            let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
7336            if !lines.is_empty() {
7337                out.push_str(&format!(
7338                    "\n=== Local repo config ({} keys) ===\n",
7339                    lines.len()
7340                ));
7341                for line in lines.iter().take(15) {
7342                    out.push_str(&format!("  {line}\n"));
7343                }
7344                if lines.len() > 15 {
7345                    out.push_str(&format!("  ... and {} more\n", lines.len() - 15));
7346                }
7347            }
7348        }
7349    }
7350
7351    if let Ok(o) = Command::new("git")
7352        .args(["config", "--global", "--get-regexp", r"alias\."])
7353        .output()
7354    {
7355        if o.status.success() {
7356            let raw = String::from_utf8_lossy(&o.stdout);
7357            let aliases: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();
7358            if !aliases.is_empty() {
7359                out.push_str(&format!("\n=== Git aliases ({}) ===\n", aliases.len()));
7360                for a in aliases.iter().take(20) {
7361                    out.push_str(&format!("  {a}\n"));
7362                }
7363                if aliases.len() > 20 {
7364                    out.push_str(&format!("  ... and {} more\n", aliases.len() - 20));
7365                }
7366            }
7367        }
7368    }
7369
7370    Ok(out.trim_end().to_string())
7371}
7372
7373// ── databases ─────────────────────────────────────────────────────────────────
7374
7375fn inspect_databases() -> Result<String, String> {
7376    let mut out = String::from("Host inspection: databases\n\n");
7377    out.push_str("Scanning for local database engines (service state, port, version)...\n\n");
7378
7379    struct DbEngine {
7380        name: &'static str,
7381        service_names: &'static [&'static str],
7382        default_port: u16,
7383        cli_name: &'static str,
7384        cli_version_args: &'static [&'static str],
7385    }
7386
7387    let engines: &[DbEngine] = &[
7388        DbEngine {
7389            name: "PostgreSQL",
7390            service_names: &[
7391                "postgresql",
7392                "postgresql-x64-14",
7393                "postgresql-x64-15",
7394                "postgresql-x64-16",
7395                "postgresql-x64-17",
7396            ],
7397
7398            default_port: 5432,
7399            cli_name: "psql",
7400            cli_version_args: &["--version"],
7401        },
7402        DbEngine {
7403            name: "MySQL",
7404            service_names: &["mysql", "mysql80", "mysql57"],
7405
7406            default_port: 3306,
7407            cli_name: "mysql",
7408            cli_version_args: &["--version"],
7409        },
7410        DbEngine {
7411            name: "MariaDB",
7412            service_names: &["mariadb", "mariadb.exe"],
7413
7414            default_port: 3306,
7415            cli_name: "mariadb",
7416            cli_version_args: &["--version"],
7417        },
7418        DbEngine {
7419            name: "MongoDB",
7420            service_names: &["mongodb", "mongod"],
7421
7422            default_port: 27017,
7423            cli_name: "mongod",
7424            cli_version_args: &["--version"],
7425        },
7426        DbEngine {
7427            name: "Redis",
7428            service_names: &["redis", "redis-server"],
7429
7430            default_port: 6379,
7431            cli_name: "redis-server",
7432            cli_version_args: &["--version"],
7433        },
7434        DbEngine {
7435            name: "SQL Server",
7436            service_names: &["mssqlserver", "mssql$sqlexpress", "mssql$localdb"],
7437
7438            default_port: 1433,
7439            cli_name: "sqlcmd",
7440            cli_version_args: &["-?"],
7441        },
7442        DbEngine {
7443            name: "SQLite",
7444            service_names: &[], // no service — file-based
7445
7446            default_port: 0, // no port — file-based
7447            cli_name: "sqlite3",
7448            cli_version_args: &["--version"],
7449        },
7450        DbEngine {
7451            name: "CouchDB",
7452            service_names: &["couchdb", "apache-couchdb"],
7453
7454            default_port: 5984,
7455            cli_name: "couchdb",
7456            cli_version_args: &["--version"],
7457        },
7458        DbEngine {
7459            name: "Cassandra",
7460            service_names: &["cassandra"],
7461
7462            default_port: 9042,
7463            cli_name: "cqlsh",
7464            cli_version_args: &["--version"],
7465        },
7466        DbEngine {
7467            name: "Elasticsearch",
7468            service_names: &["elasticsearch-service-x64", "elasticsearch"],
7469
7470            default_port: 9200,
7471            cli_name: "elasticsearch",
7472            cli_version_args: &["--version"],
7473        },
7474    ];
7475
7476    // Helper: check if port is listening
7477    fn port_listening(port: u16) -> bool {
7478        if port == 0 {
7479            return false;
7480        }
7481        // Use netstat-style check via connecting
7482        std::net::TcpStream::connect_timeout(
7483            &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
7484            std::time::Duration::from_millis(150),
7485        )
7486        .is_ok()
7487    }
7488
7489    let mut found_any = false;
7490
7491    for engine in engines {
7492        let mut status_parts: Vec<String> = Vec::new();
7493        let mut detected = false;
7494
7495        // 1. CLI version check (fastest — works cross-platform)
7496        let version = Command::new(engine.cli_name)
7497            .args(engine.cli_version_args)
7498            .output()
7499            .ok()
7500            .and_then(|o| {
7501                let combined = if o.stdout.is_empty() {
7502                    String::from_utf8_lossy(&o.stderr).trim().to_string()
7503                } else {
7504                    String::from_utf8_lossy(&o.stdout).trim().to_string()
7505                };
7506                // Take just the first line
7507                combined.lines().next().map(|l| l.trim().to_string())
7508            });
7509
7510        if let Some(ref ver) = version {
7511            if !ver.is_empty() {
7512                status_parts.push(format!("version: {ver}"));
7513                detected = true;
7514            }
7515        }
7516
7517        // 2. Port check
7518        if engine.default_port > 0 && port_listening(engine.default_port) {
7519            status_parts.push(format!("listening on :{}", engine.default_port));
7520            detected = true;
7521        } else if engine.default_port > 0 && detected {
7522            status_parts.push(format!("not listening on :{}", engine.default_port));
7523        }
7524
7525        // 3. Windows service check
7526        #[cfg(target_os = "windows")]
7527        {
7528            if !engine.service_names.is_empty() {
7529                let service_list = engine.service_names.join("','");
7530                let script = format!(
7531                    r#"$names = @('{}'); foreach ($n in $names) {{ $s = Get-Service -Name $n -ErrorAction SilentlyContinue; if ($s) {{ $n + ':' + $s.Status; break }} }}"#,
7532                    service_list
7533                );
7534                if let Ok(o) = Command::new("powershell")
7535                    .args(["-NoProfile", "-Command", &script])
7536                    .output()
7537                {
7538                    let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
7539                    if !text.is_empty() {
7540                        let parts: Vec<&str> = text.splitn(2, ':').collect();
7541                        let svc_name = parts.first().map(|s| s.trim()).unwrap_or("");
7542                        let svc_state = parts.get(1).map(|s| s.trim()).unwrap_or("unknown");
7543                        status_parts.push(format!("service '{svc_name}': {svc_state}"));
7544                        detected = true;
7545                    }
7546                }
7547            }
7548        }
7549
7550        // 4. Linux/macOS systemctl / launchctl check
7551        #[cfg(not(target_os = "windows"))]
7552        {
7553            for svc in engine.service_names {
7554                if let Ok(o) = Command::new("systemctl").args(["is-active", svc]).output() {
7555                    let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
7556                    if !state.is_empty() && state != "inactive" {
7557                        status_parts.push(format!("systemd '{svc}': {state}"));
7558                        detected = true;
7559                        break;
7560                    }
7561                }
7562            }
7563        }
7564
7565        if detected {
7566            found_any = true;
7567            let label = if engine.default_port > 0 {
7568                format!("{} (default port: {})", engine.name, engine.default_port)
7569            } else {
7570                format!("{} (file-based, no port)", engine.name)
7571            };
7572            out.push_str(&format!("[FOUND] {label}\n"));
7573            for part in &status_parts {
7574                out.push_str(&format!("  {part}\n"));
7575            }
7576            out.push('\n');
7577        }
7578    }
7579
7580    if !found_any {
7581        out.push_str("No local database engines detected.\n");
7582        out.push_str("(Checked: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQL Server, SQLite, CouchDB, Cassandra, Elasticsearch)\n\n");
7583        out.push_str(
7584            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
7585        );
7586    } else {
7587        out.push_str("---\n");
7588        out.push_str(
7589            "Note: databases running inside Docker containers are listed under topic='docker'.\n",
7590        );
7591        out.push_str("This topic checks service state and port reachability only — no credentials or queries are used.\n");
7592    }
7593
7594    Ok(out.trim_end().to_string())
7595}
7596
7597// ── user_accounts ─────────────────────────────────────────────────────────────
7598
7599fn inspect_user_accounts(max_entries: usize) -> Result<String, String> {
7600    let mut out = String::from("Host inspection: user_accounts\n\n");
7601
7602    #[cfg(target_os = "windows")]
7603    {
7604        let users_out = Command::new("powershell")
7605            .args([
7606                "-NoProfile", "-NonInteractive", "-Command",
7607                "Get-LocalUser | ForEach-Object { $logon = if ($_.LastLogon) { $_.LastLogon.ToString('yyyy-MM-dd HH:mm') } else { 'never' }; \"  $($_.Name) | Enabled: $($_.Enabled) | LastLogon: $logon | PwdRequired: $($_.PasswordRequired) | $($_.Description)\" }",
7608            ])
7609            .output()
7610            .ok()
7611            .and_then(|o| String::from_utf8(o.stdout).ok())
7612            .unwrap_or_default();
7613
7614        out.push_str("=== Local User Accounts ===\n");
7615        if users_out.trim().is_empty() {
7616            out.push_str("  (requires elevation or Get-LocalUser unavailable)\n");
7617        } else {
7618            for line in users_out.lines().take(max_entries) {
7619                if !line.trim().is_empty() {
7620                    out.push_str(line);
7621                    out.push('\n');
7622                }
7623            }
7624        }
7625
7626        let admins_out = Command::new("powershell")
7627            .args([
7628                "-NoProfile", "-NonInteractive", "-Command",
7629                "Get-LocalGroupMember -Group 'Administrators' 2>$null | ForEach-Object { \"  $($_.ObjectClass): $($_.Name)\" }",
7630            ])
7631            .output()
7632            .ok()
7633            .and_then(|o| String::from_utf8(o.stdout).ok())
7634            .unwrap_or_default();
7635
7636        out.push_str("\n=== Administrators Group Members ===\n");
7637        if admins_out.trim().is_empty() {
7638            out.push_str("  (unable to retrieve)\n");
7639        } else {
7640            out.push_str(admins_out.trim());
7641            out.push('\n');
7642        }
7643
7644        let sessions_out = Command::new("powershell")
7645            .args([
7646                "-NoProfile",
7647                "-NonInteractive",
7648                "-Command",
7649                "query user 2>$null",
7650            ])
7651            .output()
7652            .ok()
7653            .and_then(|o| String::from_utf8(o.stdout).ok())
7654            .unwrap_or_default();
7655
7656        out.push_str("\n=== Active Logon Sessions ===\n");
7657        if sessions_out.trim().is_empty() {
7658            out.push_str("  (none or requires elevation)\n");
7659        } else {
7660            for line in sessions_out.lines().take(max_entries) {
7661                if !line.trim().is_empty() {
7662                    out.push_str(&format!("  {}\n", line));
7663                }
7664            }
7665        }
7666
7667        let is_admin = Command::new("powershell")
7668            .args([
7669                "-NoProfile", "-NonInteractive", "-Command",
7670                "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
7671            ])
7672            .output()
7673            .ok()
7674            .and_then(|o| String::from_utf8(o.stdout).ok())
7675            .map(|s| s.trim().to_lowercase())
7676            .unwrap_or_default();
7677
7678        out.push_str("\n=== Current Session Elevation ===\n");
7679        out.push_str(&format!(
7680            "  Running as Administrator: {}\n",
7681            if is_admin.contains("true") {
7682                "YES"
7683            } else {
7684                "no"
7685            }
7686        ));
7687    }
7688
7689    #[cfg(not(target_os = "windows"))]
7690    {
7691        let who_out = Command::new("who")
7692            .output()
7693            .ok()
7694            .and_then(|o| String::from_utf8(o.stdout).ok())
7695            .unwrap_or_default();
7696        out.push_str("=== Active Sessions ===\n");
7697        if who_out.trim().is_empty() {
7698            out.push_str("  (none)\n");
7699        } else {
7700            for line in who_out.lines().take(max_entries) {
7701                out.push_str(&format!("  {}\n", line));
7702            }
7703        }
7704        let id_out = Command::new("id")
7705            .output()
7706            .ok()
7707            .and_then(|o| String::from_utf8(o.stdout).ok())
7708            .unwrap_or_default();
7709        out.push_str(&format!("\n=== Current User ===\n  {}\n", id_out.trim()));
7710    }
7711
7712    Ok(out.trim_end().to_string())
7713}
7714
7715// ── audit_policy ──────────────────────────────────────────────────────────────
7716
7717fn inspect_audit_policy() -> Result<String, String> {
7718    let mut out = String::from("Host inspection: audit_policy\n\n");
7719
7720    #[cfg(target_os = "windows")]
7721    {
7722        let auditpol_out = Command::new("auditpol")
7723            .args(["/get", "/category:*"])
7724            .output()
7725            .ok()
7726            .and_then(|o| String::from_utf8(o.stdout).ok())
7727            .unwrap_or_default();
7728
7729        if auditpol_out.trim().is_empty()
7730            || auditpol_out.to_lowercase().contains("access is denied")
7731        {
7732            out.push_str("Audit policy requires Administrator elevation to read.\n");
7733            out.push_str(
7734                "Run Hematite as Administrator, or check manually: auditpol /get /category:*\n",
7735            );
7736        } else {
7737            out.push_str("=== Windows Audit Policy ===\n");
7738            let mut any_enabled = false;
7739            for line in auditpol_out.lines() {
7740                let trimmed = line.trim();
7741                if trimmed.is_empty() {
7742                    continue;
7743                }
7744                if trimmed.contains("Success") || trimmed.contains("Failure") {
7745                    out.push_str(&format!("  [ENABLED] {}\n", trimmed));
7746                    any_enabled = true;
7747                } else {
7748                    out.push_str(&format!("  {}\n", trimmed));
7749                }
7750            }
7751            if !any_enabled {
7752                out.push_str("\n[WARNING] No audit categories are enabled — security events will not be logged.\n");
7753                out.push_str(
7754                    "Minimum recommended: enable Logon/Logoff and Account Logon success+failure.\n",
7755                );
7756            }
7757        }
7758
7759        let evtlog = Command::new("powershell")
7760            .args([
7761                "-NoProfile", "-NonInteractive", "-Command",
7762                "Get-Service EventLog -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Status",
7763            ])
7764            .output()
7765            .ok()
7766            .and_then(|o| String::from_utf8(o.stdout).ok())
7767            .map(|s| s.trim().to_string())
7768            .unwrap_or_default();
7769
7770        out.push_str(&format!(
7771            "\n=== Windows Event Log Service ===\n  Status: {}\n",
7772            if evtlog.is_empty() {
7773                "unknown".to_string()
7774            } else {
7775                evtlog
7776            }
7777        ));
7778    }
7779
7780    #[cfg(not(target_os = "windows"))]
7781    {
7782        let auditd_status = Command::new("systemctl")
7783            .args(["is-active", "auditd"])
7784            .output()
7785            .ok()
7786            .and_then(|o| String::from_utf8(o.stdout).ok())
7787            .map(|s| s.trim().to_string())
7788            .unwrap_or_else(|| "not found".to_string());
7789
7790        out.push_str(&format!(
7791            "=== auditd service ===\n  Status: {}\n",
7792            auditd_status
7793        ));
7794
7795        if auditd_status == "active" {
7796            let rules = Command::new("auditctl")
7797                .args(["-l"])
7798                .output()
7799                .ok()
7800                .and_then(|o| String::from_utf8(o.stdout).ok())
7801                .unwrap_or_default();
7802            out.push_str("\n=== Active Audit Rules ===\n");
7803            if rules.trim().is_empty() || rules.contains("No rules") {
7804                out.push_str("  No rules configured.\n");
7805            } else {
7806                for line in rules.lines() {
7807                    out.push_str(&format!("  {}\n", line));
7808                }
7809            }
7810        }
7811    }
7812
7813    Ok(out.trim_end().to_string())
7814}
7815
7816// ── shares ────────────────────────────────────────────────────────────────────
7817
7818fn inspect_shares(max_entries: usize) -> Result<String, String> {
7819    let mut out = String::from("Host inspection: shares\n\n");
7820
7821    #[cfg(target_os = "windows")]
7822    {
7823        let smb_out = Command::new("powershell")
7824            .args([
7825                "-NoProfile", "-NonInteractive", "-Command",
7826                "Get-SmbShare | ForEach-Object { \"  $($_.Name) | Path: $($_.Path) | State: $($_.ShareState) | Encrypted: $($_.EncryptData) | $($_.Description)\" }",
7827            ])
7828            .output()
7829            .ok()
7830            .and_then(|o| String::from_utf8(o.stdout).ok())
7831            .unwrap_or_default();
7832
7833        out.push_str("=== SMB Shares (exposed by this machine) ===\n");
7834        let smb_lines: Vec<&str> = smb_out
7835            .lines()
7836            .filter(|l| !l.trim().is_empty())
7837            .take(max_entries)
7838            .collect();
7839        if smb_lines.is_empty() {
7840            out.push_str("  No SMB shares or unable to retrieve.\n");
7841        } else {
7842            for line in &smb_lines {
7843                let name = line.trim().split('|').next().unwrap_or("").trim();
7844                if name.ends_with('$') {
7845                    out.push_str(&format!("  {}\n", line.trim()));
7846                } else {
7847                    out.push_str(&format!("  [CUSTOM] {}\n", line.trim()));
7848                }
7849            }
7850        }
7851
7852        let smb_security = Command::new("powershell")
7853            .args([
7854                "-NoProfile", "-NonInteractive", "-Command",
7855                "Get-SmbServerConfiguration | ForEach-Object { \"  SMB1: $($_.EnableSMB1Protocol) | SMB2: $($_.EnableSMB2Protocol) | Signing Required: $($_.RequireSecuritySignature) | Encryption: $($_.EncryptData)\" }",
7856            ])
7857            .output()
7858            .ok()
7859            .and_then(|o| String::from_utf8(o.stdout).ok())
7860            .unwrap_or_default();
7861
7862        out.push_str("\n=== SMB Server Security Settings ===\n");
7863        if smb_security.trim().is_empty() {
7864            out.push_str("  (unable to retrieve)\n");
7865        } else {
7866            out.push_str(smb_security.trim());
7867            out.push('\n');
7868            if smb_security.to_lowercase().contains("smb1: true") {
7869                out.push_str("  [WARNING] SMB1 is ENABLED — disable it: Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force\n");
7870            }
7871        }
7872
7873        let drives_out = Command::new("powershell")
7874            .args([
7875                "-NoProfile", "-NonInteractive", "-Command",
7876                "Get-PSDrive -PSProvider FileSystem | Where-Object { $_.DisplayRoot } | ForEach-Object { \"  $($_.Name): -> $($_.DisplayRoot)\" }",
7877            ])
7878            .output()
7879            .ok()
7880            .and_then(|o| String::from_utf8(o.stdout).ok())
7881            .unwrap_or_default();
7882
7883        out.push_str("\n=== Mapped Network Drives ===\n");
7884        if drives_out.trim().is_empty() {
7885            out.push_str("  None.\n");
7886        } else {
7887            for line in drives_out.lines().take(max_entries) {
7888                if !line.trim().is_empty() {
7889                    out.push_str(line);
7890                    out.push('\n');
7891                }
7892            }
7893        }
7894    }
7895
7896    #[cfg(not(target_os = "windows"))]
7897    {
7898        let smb_conf = std::fs::read_to_string("/etc/samba/smb.conf").unwrap_or_default();
7899        out.push_str("=== Samba Config (/etc/samba/smb.conf) ===\n");
7900        if smb_conf.is_empty() {
7901            out.push_str("  Not found or Samba not installed.\n");
7902        } else {
7903            for line in smb_conf.lines().take(max_entries) {
7904                out.push_str(&format!("  {}\n", line));
7905            }
7906        }
7907        let nfs_exports = std::fs::read_to_string("/etc/exports").unwrap_or_default();
7908        out.push_str("\n=== NFS Exports (/etc/exports) ===\n");
7909        if nfs_exports.is_empty() {
7910            out.push_str("  Not configured.\n");
7911        } else {
7912            for line in nfs_exports.lines().take(max_entries) {
7913                out.push_str(&format!("  {}\n", line));
7914            }
7915        }
7916    }
7917
7918    Ok(out.trim_end().to_string())
7919}
7920
7921// ── dns_servers ───────────────────────────────────────────────────────────────
7922
7923fn inspect_dns_servers() -> Result<String, String> {
7924    let mut out = String::from("Host inspection: dns_servers\n\n");
7925
7926    #[cfg(target_os = "windows")]
7927    {
7928        let dns_out = Command::new("powershell")
7929            .args([
7930                "-NoProfile", "-NonInteractive", "-Command",
7931                "Get-DnsClientServerAddress | Where-Object { $_.ServerAddresses.Count -gt 0 } | ForEach-Object { $addrs = $_.ServerAddresses -join ', '; \"  $($_.InterfaceAlias) (AF $($_.AddressFamily)): $addrs\" }",
7932            ])
7933            .output()
7934            .ok()
7935            .and_then(|o| String::from_utf8(o.stdout).ok())
7936            .unwrap_or_default();
7937
7938        out.push_str("=== Configured DNS Resolvers (per adapter) ===\n");
7939        if dns_out.trim().is_empty() {
7940            out.push_str("  (unable to retrieve)\n");
7941        } else {
7942            for line in dns_out.lines() {
7943                if line.trim().is_empty() {
7944                    continue;
7945                }
7946                let mut annotation = "";
7947                if line.contains("8.8.8.8") || line.contains("8.8.4.4") {
7948                    annotation = "  <- Google Public DNS";
7949                } else if line.contains("1.1.1.1") || line.contains("1.0.0.1") {
7950                    annotation = "  <- Cloudflare DNS";
7951                } else if line.contains("9.9.9.9") {
7952                    annotation = "  <- Quad9";
7953                } else if line.contains("208.67.222") || line.contains("208.67.220") {
7954                    annotation = "  <- OpenDNS";
7955                }
7956                out.push_str(line);
7957                out.push_str(annotation);
7958                out.push('\n');
7959            }
7960        }
7961
7962        let doh_out = Command::new("powershell")
7963            .args([
7964                "-NoProfile", "-NonInteractive", "-Command",
7965                "Get-DnsClientDohServerAddress 2>$null | ForEach-Object { \"  $($_.ServerAddress): $($_.DohTemplate)\" }",
7966            ])
7967            .output()
7968            .ok()
7969            .and_then(|o| String::from_utf8(o.stdout).ok())
7970            .unwrap_or_default();
7971
7972        out.push_str("\n=== DNS over HTTPS (DoH) ===\n");
7973        if doh_out.trim().is_empty() {
7974            out.push_str("  Not configured (plain DNS).\n");
7975        } else {
7976            out.push_str(doh_out.trim());
7977            out.push('\n');
7978        }
7979
7980        let suffixes = Command::new("powershell")
7981            .args([
7982                "-NoProfile", "-NonInteractive", "-Command",
7983                "Get-DnsClientGlobalSetting | Select-Object -ExpandProperty SuffixSearchList | ForEach-Object { \"  $_\" }",
7984            ])
7985            .output()
7986            .ok()
7987            .and_then(|o| String::from_utf8(o.stdout).ok())
7988            .unwrap_or_default();
7989
7990        if !suffixes.trim().is_empty() {
7991            out.push_str("\n=== DNS Search Suffix List ===\n");
7992            out.push_str(suffixes.trim());
7993            out.push('\n');
7994        }
7995    }
7996
7997    #[cfg(not(target_os = "windows"))]
7998    {
7999        let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
8000        out.push_str("=== /etc/resolv.conf ===\n");
8001        if resolv.is_empty() {
8002            out.push_str("  Not found.\n");
8003        } else {
8004            for line in resolv.lines() {
8005                if !line.trim().is_empty() && !line.starts_with('#') {
8006                    out.push_str(&format!("  {}\n", line));
8007                }
8008            }
8009        }
8010        let resolved_out = Command::new("resolvectl")
8011            .args(["status", "--no-pager"])
8012            .output()
8013            .ok()
8014            .and_then(|o| String::from_utf8(o.stdout).ok())
8015            .unwrap_or_default();
8016        if !resolved_out.is_empty() {
8017            out.push_str("\n=== systemd-resolved ===\n");
8018            for line in resolved_out.lines().take(30) {
8019                out.push_str(&format!("  {}\n", line));
8020            }
8021        }
8022    }
8023
8024    Ok(out.trim_end().to_string())
8025}
8026
8027fn inspect_bitlocker() -> Result<String, String> {
8028    let mut out = String::from("Host inspection: bitlocker\n\n");
8029
8030    #[cfg(target_os = "windows")]
8031    {
8032        let ps_cmd = "Get-BitLockerVolume | Select-Object MountPoint, VolumeStatus, ProtectionStatus, EncryptionPercentage | ForEach-Object { \"$($_.MountPoint) [$($_.VolumeStatus)] Protection:$($_.ProtectionStatus) ($($_.EncryptionPercentage)%)\" }";
8033        let output = Command::new("powershell")
8034            .args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
8035            .output()
8036            .map_err(|e| format!("Failed to execute PowerShell: {e}"))?;
8037
8038        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
8039        let stderr = String::from_utf8(output.stderr).unwrap_or_default();
8040
8041        if !stdout.trim().is_empty() {
8042            out.push_str("=== BitLocker Volumes ===\n");
8043            for line in stdout.lines() {
8044                out.push_str(&format!("  {}\n", line));
8045            }
8046        } else if !stderr.trim().is_empty() {
8047            if stderr.contains("Access is denied") {
8048                out.push_str("Error: Access denied. BitLocker diagnostics require Administrator elevation.\n");
8049            } else {
8050                out.push_str(&format!(
8051                    "Error retrieving BitLocker info: {}\n",
8052                    stderr.trim()
8053                ));
8054            }
8055        } else {
8056            out.push_str("No BitLocker volumes detected or access denied.\n");
8057        }
8058    }
8059
8060    #[cfg(not(target_os = "windows"))]
8061    {
8062        out.push_str(
8063            "BitLocker is a Windows-specific technology. Checking for LUKS/dm-crypt...\n\n",
8064        );
8065        let lsblk = Command::new("lsblk")
8066            .args(["-f", "-o", "NAME,FSTYPE,MOUNTPOINT"])
8067            .output()
8068            .ok()
8069            .and_then(|o| String::from_utf8(o.stdout).ok())
8070            .unwrap_or_default();
8071        if lsblk.contains("crypto_LUKS") {
8072            out.push_str("=== LUKS Encrypted Volumes ===\n");
8073            for line in lsblk.lines().filter(|l| l.contains("crypto_LUKS")) {
8074                out.push_str(&format!("  {}\n", line));
8075            }
8076        } else {
8077            out.push_str("No LUKS encrypted volumes detected via lsblk.\n");
8078        }
8079    }
8080
8081    Ok(out.trim_end().to_string())
8082}
8083
8084fn inspect_rdp() -> Result<String, String> {
8085    let mut out = String::from("Host inspection: rdp\n\n");
8086
8087    #[cfg(target_os = "windows")]
8088    {
8089        let reg_path = "HKLM:\\System\\CurrentControlSet\\Control\\Terminal Server";
8090        let f_deny = Command::new("powershell")
8091            .args([
8092                "-NoProfile",
8093                "-Command",
8094                &format!("(Get-ItemProperty '{}').fDenyTSConnections", reg_path),
8095            ])
8096            .output()
8097            .ok()
8098            .and_then(|o| String::from_utf8(o.stdout).ok())
8099            .unwrap_or_default()
8100            .trim()
8101            .to_string();
8102
8103        let status = if f_deny == "0" { "ENABLED" } else { "DISABLED" };
8104        out.push_str(&format!("=== RDP Status: {} ===\n", status));
8105
8106        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"])
8107            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default().trim().to_string();
8108        out.push_str(&format!(
8109            "  Port: {}\n",
8110            if port.is_empty() {
8111                "3389 (default)"
8112            } else {
8113                &port
8114            }
8115        ));
8116
8117        let nla = Command::new("powershell")
8118            .args([
8119                "-NoProfile",
8120                "-Command",
8121                &format!("(Get-ItemProperty '{}').UserAuthentication", reg_path),
8122            ])
8123            .output()
8124            .ok()
8125            .and_then(|o| String::from_utf8(o.stdout).ok())
8126            .unwrap_or_default()
8127            .trim()
8128            .to_string();
8129        out.push_str(&format!(
8130            "  NLA Required: {}\n",
8131            if nla == "1" { "Yes" } else { "No" }
8132        ));
8133
8134        out.push_str("\n=== Active Sessions ===\n");
8135        let qwinsta = Command::new("qwinsta")
8136            .output()
8137            .ok()
8138            .and_then(|o| String::from_utf8(o.stdout).ok())
8139            .unwrap_or_default();
8140        if qwinsta.trim().is_empty() {
8141            out.push_str("  No active sessions listed.\n");
8142        } else {
8143            for line in qwinsta.lines() {
8144                out.push_str(&format!("  {}\n", line));
8145            }
8146        }
8147
8148        out.push_str("\n=== Firewall Rule Check ===\n");
8149        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))\" }"])
8150            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8151        if fw.trim().is_empty() {
8152            out.push_str("  No enabled RDP firewall rules found.\n");
8153        } else {
8154            out.push_str(fw.trim_end());
8155            out.push('\n');
8156        }
8157    }
8158
8159    #[cfg(not(target_os = "windows"))]
8160    {
8161        out.push_str("Checking for common RDP/VNC listeners (3389, 5900-5905)...\n");
8162        let ss = Command::new("ss")
8163            .args(["-tlnp"])
8164            .output()
8165            .ok()
8166            .and_then(|o| String::from_utf8(o.stdout).ok())
8167            .unwrap_or_default();
8168        let matches: Vec<&str> = ss
8169            .lines()
8170            .filter(|l| l.contains(":3389") || l.contains(":590"))
8171            .collect();
8172        if matches.is_empty() {
8173            out.push_str("  No RDP/VNC listeners detected via 'ss'.\n");
8174        } else {
8175            for m in matches {
8176                out.push_str(&format!("  {}\n", m));
8177            }
8178        }
8179    }
8180
8181    Ok(out.trim_end().to_string())
8182}
8183
8184fn inspect_shadow_copies() -> Result<String, String> {
8185    let mut out = String::from("Host inspection: shadow_copies\n\n");
8186
8187    #[cfg(target_os = "windows")]
8188    {
8189        let output = Command::new("vssadmin")
8190            .args(["list", "shadows"])
8191            .output()
8192            .map_err(|e| format!("Failed to run vssadmin: {e}"))?;
8193        let stdout = String::from_utf8(output.stdout).unwrap_or_default();
8194
8195        if stdout.contains("No items found") || stdout.trim().is_empty() {
8196            out.push_str("No Volume Shadow Copies found.\n");
8197        } else {
8198            out.push_str("=== Volume Shadow Copies ===\n");
8199            for line in stdout.lines().take(50) {
8200                if line.contains("Creation Time:")
8201                    || line.contains("Contents:")
8202                    || line.contains("Volume Name:")
8203                {
8204                    out.push_str(&format!("  {}\n", line.trim()));
8205                }
8206            }
8207        }
8208
8209        out.push_str("\n=== Shadow Copy Storage ===\n");
8210        let storage_out = Command::new("vssadmin")
8211            .args(["list", "shadowstorage"])
8212            .output()
8213            .ok();
8214        if let Some(o) = storage_out {
8215            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8216            for line in stdout.lines() {
8217                if line.contains("Used Shadow Copy Storage space:")
8218                    || line.contains("Max Shadow Copy Storage space:")
8219                {
8220                    out.push_str(&format!("  {}\n", line.trim()));
8221                }
8222            }
8223        }
8224    }
8225
8226    #[cfg(not(target_os = "windows"))]
8227    {
8228        out.push_str("Checking for LVM snapshots or Btrfs subvolumes...\n\n");
8229        let lvs = Command::new("lvs")
8230            .output()
8231            .ok()
8232            .and_then(|o| String::from_utf8(o.stdout).ok())
8233            .unwrap_or_default();
8234        if !lvs.is_empty() {
8235            out.push_str("=== LVM Volumes (checking for snapshots) ===\n");
8236            out.push_str(&lvs);
8237        } else {
8238            out.push_str("No LVM volumes detected.\n");
8239        }
8240    }
8241
8242    Ok(out.trim_end().to_string())
8243}
8244
8245fn inspect_pagefile() -> Result<String, String> {
8246    let mut out = String::from("Host inspection: pagefile\n\n");
8247
8248    #[cfg(target_os = "windows")]
8249    {
8250        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)\" }";
8251        let output = Command::new("powershell")
8252            .args(["-NoProfile", "-Command", ps_cmd])
8253            .output()
8254            .ok()
8255            .and_then(|o| String::from_utf8(o.stdout).ok())
8256            .unwrap_or_default();
8257
8258        if output.trim().is_empty() {
8259            out.push_str("No page files detected (system may be running without a page file or managed differently).\n");
8260            let managed = Command::new("powershell")
8261                .args([
8262                    "-NoProfile",
8263                    "-Command",
8264                    "(Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile",
8265                ])
8266                .output()
8267                .ok()
8268                .and_then(|o| String::from_utf8(o.stdout).ok())
8269                .unwrap_or_default()
8270                .trim()
8271                .to_string();
8272            out.push_str(&format!("Automatic Managed Pagefile: {}\n", managed));
8273        } else {
8274            out.push_str("=== Page File Usage ===\n");
8275            out.push_str(&output);
8276        }
8277    }
8278
8279    #[cfg(not(target_os = "windows"))]
8280    {
8281        out.push_str("=== Swap Usage (Linux/macOS) ===\n");
8282        let swap = Command::new("swapon")
8283            .args(["--show"])
8284            .output()
8285            .ok()
8286            .and_then(|o| String::from_utf8(o.stdout).ok())
8287            .unwrap_or_default();
8288        if swap.is_empty() {
8289            let free = Command::new("free")
8290                .args(["-h"])
8291                .output()
8292                .ok()
8293                .and_then(|o| String::from_utf8(o.stdout).ok())
8294                .unwrap_or_default();
8295            out.push_str(&free);
8296        } else {
8297            out.push_str(&swap);
8298        }
8299    }
8300
8301    Ok(out.trim_end().to_string())
8302}
8303
8304fn inspect_windows_features(max_entries: usize) -> Result<String, String> {
8305    let mut out = String::from("Host inspection: windows_features\n\n");
8306
8307    #[cfg(target_os = "windows")]
8308    {
8309        out.push_str("=== Quick Check: Notable Features ===\n");
8310        let quick_ps = "Get-WindowsOptionalFeature -Online | Where-Object { $_.FeatureName -match 'IIS|Hyper-V|VirtualMachinePlatform|Subsystem-Linux' -and $_.State -eq 'Enabled' } | Select-Object -ExpandProperty FeatureName";
8311        let output = Command::new("powershell")
8312            .args(["-NoProfile", "-Command", quick_ps])
8313            .output()
8314            .ok();
8315
8316        if let Some(o) = output {
8317            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8318            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8319
8320            if !stdout.trim().is_empty() {
8321                for f in stdout.lines() {
8322                    out.push_str(&format!("  [ENABLED] {}\n", f));
8323                }
8324            } else if stderr.contains("Access is denied") || stderr.contains("requires elevation") {
8325                out.push_str("  Error: Access denied. Listing Windows Features requires Administrator elevation.\n");
8326            } else if quick_ps.contains("-Online") && stdout.trim().is_empty() {
8327                out.push_str(
8328                    "  No major features (IIS, Hyper-V, WSL) appear enabled in the quick check.\n",
8329                );
8330            }
8331        }
8332
8333        out.push_str(&format!(
8334            "\n=== All Enabled Features (capped at {}) ===\n",
8335            max_entries
8336        ));
8337        let all_ps = format!("Get-WindowsOptionalFeature -Online | Where-Object {{$_.State -eq 'Enabled'}} | Select-Object -First {} -ExpandProperty FeatureName", max_entries);
8338        let all_out = Command::new("powershell")
8339            .args(["-NoProfile", "-Command", &all_ps])
8340            .output()
8341            .ok();
8342        if let Some(o) = all_out {
8343            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8344            if !stdout.trim().is_empty() {
8345                out.push_str(&stdout);
8346            }
8347        }
8348    }
8349
8350    #[cfg(not(target_os = "windows"))]
8351    {
8352        let _ = max_entries;
8353        out.push_str("Windows Optional Features are Windows-specific. On Linux, check your package manager.\n");
8354    }
8355
8356    Ok(out.trim_end().to_string())
8357}
8358
8359fn inspect_printers(max_entries: usize) -> Result<String, String> {
8360    let mut out = String::from("Host inspection: printers\n\n");
8361
8362    #[cfg(target_os = "windows")]
8363    {
8364        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)])
8365            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8366        if list.trim().is_empty() {
8367            out.push_str("No printers detected.\n");
8368        } else {
8369            out.push_str("=== Installed Printers ===\n");
8370            out.push_str(&list);
8371        }
8372
8373        let jobs = Command::new("powershell").args(["-NoProfile", "-Command", "Get-PrintJob | Select-Object PrinterName, ID, DocumentName, Status | ForEach-Object { \"  [$($_.PrinterName)] Job $($_.ID): $($_.DocumentName) - $($_.Status)\" }"])
8374            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8375        if !jobs.trim().is_empty() {
8376            out.push_str("\n=== Active Print Jobs ===\n");
8377            out.push_str(&jobs);
8378        }
8379    }
8380
8381    #[cfg(not(target_os = "windows"))]
8382    {
8383        let _ = max_entries;
8384        out.push_str("Checking LPSTAT for printers...\n");
8385        let lpstat = Command::new("lpstat")
8386            .args(["-p", "-d"])
8387            .output()
8388            .ok()
8389            .and_then(|o| String::from_utf8(o.stdout).ok())
8390            .unwrap_or_default();
8391        if lpstat.is_empty() {
8392            out.push_str("  No CUPS/LP printers found.\n");
8393        } else {
8394            out.push_str(&lpstat);
8395        }
8396    }
8397
8398    Ok(out.trim_end().to_string())
8399}
8400
8401fn inspect_winrm() -> Result<String, String> {
8402    let mut out = String::from("Host inspection: winrm\n\n");
8403
8404    #[cfg(target_os = "windows")]
8405    {
8406        let svc = Command::new("powershell")
8407            .args(["-NoProfile", "-Command", "(Get-Service WinRM).Status"])
8408            .output()
8409            .ok()
8410            .and_then(|o| String::from_utf8(o.stdout).ok())
8411            .unwrap_or_default()
8412            .trim()
8413            .to_string();
8414        out.push_str(&format!(
8415            "WinRM Service Status: {}\n\n",
8416            if svc.is_empty() { "NOT_FOUND" } else { &svc }
8417        ));
8418
8419        out.push_str("=== WinRM Listeners ===\n");
8420        let output = Command::new("powershell")
8421            .args([
8422                "-NoProfile",
8423                "-Command",
8424                "winrm enumerate winrm/config/listener 2>$null",
8425            ])
8426            .output()
8427            .ok();
8428        if let Some(o) = output {
8429            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8430            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8431
8432            if !stdout.trim().is_empty() {
8433                for line in stdout.lines() {
8434                    if line.contains("Address =")
8435                        || line.contains("Transport =")
8436                        || line.contains("Port =")
8437                    {
8438                        out.push_str(&format!("  {}\n", line.trim()));
8439                    }
8440                }
8441            } else if stderr.contains("Access is denied") {
8442                out.push_str("  Error: Access denied to WinRM configuration.\n");
8443            } else {
8444                out.push_str("  No listeners configured.\n");
8445            }
8446        }
8447
8448        out.push_str("\n=== PowerShell Remoting Test (Local) ===\n");
8449        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))\" }"])
8450            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8451        if test_out.trim().is_empty() {
8452            out.push_str("  WinRM not responding to local WS-Man requests.\n");
8453        } else {
8454            out.push_str(&test_out);
8455        }
8456    }
8457
8458    #[cfg(not(target_os = "windows"))]
8459    {
8460        out.push_str(
8461            "WinRM is primarily a Windows technology. Checking for listening port 5985/5986...\n",
8462        );
8463        let ss = Command::new("ss")
8464            .args(["-tln"])
8465            .output()
8466            .ok()
8467            .and_then(|o| String::from_utf8(o.stdout).ok())
8468            .unwrap_or_default();
8469        if ss.contains(":5985") || ss.contains(":5986") {
8470            out.push_str("  WinRM ports (5985/5986) are listening.\n");
8471        } else {
8472            out.push_str("  WinRM ports not detected.\n");
8473        }
8474    }
8475
8476    Ok(out.trim_end().to_string())
8477}
8478
8479fn inspect_network_stats(max_entries: usize) -> Result<String, String> {
8480    let mut out = String::from("Host inspection: network_stats\n\n");
8481
8482    #[cfg(target_os = "windows")]
8483    {
8484        let ps_cmd = format!(
8485            "$s1 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes; \
8486             Start-Sleep -Milliseconds 250; \
8487             $s2 = Get-NetAdapterStatistics -ErrorAction SilentlyContinue | Select-Object Name, ReceivedBytes, SentBytes, ReceivedPacketErrors, OutboundPacketErrors | Select-Object -First {}; \
8488             $s2 | ForEach-Object {{ \
8489                $name = $_.Name; \
8490                $prev = $s1 | Where-Object {{ $_.Name -eq $name }}; \
8491                if ($prev) {{ \
8492                    $rb = ($_.ReceivedBytes - $prev.ReceivedBytes) / 0.25; \
8493                    $sb = ($_.SentBytes - $prev.SentBytes) / 0.25; \
8494                    $rmbps = [math]::Round(($rb * 8) / 1000000, 2); \
8495                    $smbps = [math]::Round(($sb * 8) / 1000000, 2); \
8496                    $tr = [math]::Round($_.ReceivedBytes / 1MB, 2); \
8497                    $tt = [math]::Round($_.SentBytes / 1MB, 2); \
8498                    \"  $($name): Rate(RX/TX): $($rmbps)/$($smbps) Mbps | Total: $($tr)/$($tt) MB | Errors: $($_.ReceivedPacketErrors)/$($_.OutboundPacketErrors)\" \
8499                }} \
8500             }}",
8501            max_entries
8502        );
8503        let output = Command::new("powershell")
8504            .args(["-NoProfile", "-Command", &ps_cmd])
8505            .output()
8506            .ok()
8507            .and_then(|o| String::from_utf8(o.stdout).ok())
8508            .unwrap_or_default();
8509        if output.trim().is_empty() {
8510            out.push_str("No network adapter statistics available.\n");
8511        } else {
8512            out.push_str("=== Adapter Throughput (Mbps) & Health ===\n");
8513            out.push_str(&output);
8514        }
8515
8516        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)\" } }"])
8517            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8518        if !discards.trim().is_empty() {
8519            out.push_str("\n=== Packet Discards (Non-Zero Only) ===\n");
8520            out.push_str(&discards);
8521        }
8522    }
8523
8524    #[cfg(not(target_os = "windows"))]
8525    {
8526        let _ = max_entries;
8527        out.push_str("=== Network Stats (ip -s link) ===\n");
8528        let ip_s = Command::new("ip")
8529            .args(["-s", "link"])
8530            .output()
8531            .ok()
8532            .and_then(|o| String::from_utf8(o.stdout).ok())
8533            .unwrap_or_default();
8534        if ip_s.is_empty() {
8535            let netstat = Command::new("netstat")
8536                .args(["-i"])
8537                .output()
8538                .ok()
8539                .and_then(|o| String::from_utf8(o.stdout).ok())
8540                .unwrap_or_default();
8541            out.push_str(&netstat);
8542        } else {
8543            out.push_str(&ip_s);
8544        }
8545    }
8546
8547    Ok(out.trim_end().to_string())
8548}
8549
8550fn inspect_udp_ports(max_entries: usize) -> Result<String, String> {
8551    let mut out = String::from("Host inspection: udp_ports\n\n");
8552
8553    #[cfg(target_os = "windows")]
8554    {
8555        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);
8556        let output = Command::new("powershell")
8557            .args(["-NoProfile", "-Command", &ps_cmd])
8558            .output()
8559            .ok();
8560
8561        if let Some(o) = output {
8562            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8563            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8564
8565            if !stdout.trim().is_empty() {
8566                out.push_str("=== UDP Listeners (Local:Port) ===\n");
8567                for line in stdout.lines() {
8568                    let mut note = "";
8569                    if line.contains(":53 ") {
8570                        note = " [DNS]";
8571                    } else if line.contains(":67 ") || line.contains(":68 ") {
8572                        note = " [DHCP]";
8573                    } else if line.contains(":123 ") {
8574                        note = " [NTP]";
8575                    } else if line.contains(":161 ") {
8576                        note = " [SNMP]";
8577                    } else if line.contains(":1900 ") {
8578                        note = " [SSDP/UPnP]";
8579                    } else if line.contains(":5353 ") {
8580                        note = " [mDNS]";
8581                    }
8582
8583                    out.push_str(&format!("{}{}\n", line, note));
8584                }
8585            } else if stderr.contains("Access is denied") {
8586                out.push_str("Error: Access denied. Full UDP listener details require Administrator elevation.\n");
8587            } else {
8588                out.push_str("No UDP listeners detected.\n");
8589            }
8590        }
8591    }
8592
8593    #[cfg(not(target_os = "windows"))]
8594    {
8595        let ss_out = Command::new("ss")
8596            .args(["-ulnp"])
8597            .output()
8598            .ok()
8599            .and_then(|o| String::from_utf8(o.stdout).ok())
8600            .unwrap_or_default();
8601        out.push_str("=== UDP Listeners (ss -ulnp) ===\n");
8602        if ss_out.is_empty() {
8603            let netstat_out = Command::new("netstat")
8604                .args(["-ulnp"])
8605                .output()
8606                .ok()
8607                .and_then(|o| String::from_utf8(o.stdout).ok())
8608                .unwrap_or_default();
8609            if netstat_out.is_empty() {
8610                out.push_str("  Neither 'ss' nor 'netstat' available.\n");
8611            } else {
8612                for line in netstat_out.lines().take(max_entries) {
8613                    out.push_str(&format!("  {}\n", line));
8614                }
8615            }
8616        } else {
8617            for line in ss_out.lines().take(max_entries) {
8618                out.push_str(&format!("  {}\n", line));
8619            }
8620        }
8621    }
8622
8623    Ok(out.trim_end().to_string())
8624}
8625
8626fn inspect_gpo() -> Result<String, String> {
8627    let mut out = String::from("Host inspection: gpo\n\n");
8628
8629    #[cfg(target_os = "windows")]
8630    {
8631        let output = Command::new("gpresult")
8632            .args(["/r", "/scope", "computer"])
8633            .output()
8634            .ok();
8635
8636        if let Some(o) = output {
8637            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8638            let stderr = String::from_utf8(o.stderr).unwrap_or_default();
8639
8640            if stdout.contains("Applied Group Policy Objects") {
8641                out.push_str("=== Applied Group Policy Objects (Computer Scope) ===\n");
8642                let mut capture = false;
8643                for line in stdout.lines() {
8644                    if line.contains("Applied Group Policy Objects") {
8645                        capture = true;
8646                    } else if capture && line.contains("The following GPOs were not applied") {
8647                        break;
8648                    }
8649                    if capture && !line.trim().is_empty() {
8650                        out.push_str(&format!("  {}\n", line.trim()));
8651                    }
8652                }
8653            } else if stderr.contains("Access is denied") || stdout.contains("Access is denied") {
8654                out.push_str("Error: Access denied. Group Policy inspection requires Administrator elevation.\n");
8655            } else {
8656                out.push_str("No applied Group Policy Objects detected or insufficient permissions to query computer scope.\n");
8657            }
8658        }
8659    }
8660
8661    #[cfg(not(target_os = "windows"))]
8662    {
8663        out.push_str("Group Policy (GPO) is a Windows-only topic.\n");
8664    }
8665
8666    Ok(out.trim_end().to_string())
8667}
8668
8669fn inspect_certificates(max_entries: usize) -> Result<String, String> {
8670    let mut out = String::from("Host inspection: certificates\n\n");
8671
8672    #[cfg(target_os = "windows")]
8673    {
8674        let ps_cmd = format!(
8675            "Get-ChildItem -Path Cert:\\LocalMachine\\My | Select-Object Subject, NotAfter, Thumbprint | Select-Object -First {} | ForEach-Object {{ \
8676                $days = ($_.NotAfter - (Get-Date)).Days; \
8677                $status = if ($days -lt 0) {{ \"[EXPIRED]\" }} else if ($days -lt 30) {{ \"[EXPIRING SOON ($days days)]\" }} else {{ \"\" }}; \
8678                \"  $($_.Subject) - Expires: $($_.NotAfter.ToString('yyyy-MM-dd')) $status (Thumb: $($_.Thumbprint.Substring(0,8))...)\" \
8679            }}", 
8680            max_entries
8681        );
8682        let output = Command::new("powershell")
8683            .args(["-NoProfile", "-Command", &ps_cmd])
8684            .output()
8685            .ok();
8686
8687        if let Some(o) = output {
8688            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8689            if !stdout.trim().is_empty() {
8690                out.push_str("=== Local Machine Certificates (Personal Store) ===\n");
8691                out.push_str(&stdout);
8692            } else {
8693                out.push_str("No certificates found in the Local Machine Personal store.\n");
8694            }
8695        }
8696    }
8697
8698    #[cfg(not(target_os = "windows"))]
8699    {
8700        let _ = max_entries;
8701        out.push_str("Host inspection: certificates (Linux/macOS)\n\n");
8702        // Check standard cert locations
8703        for path in ["/etc/ssl/certs", "/etc/pki/tls/certs"] {
8704            if Path::new(path).exists() {
8705                out.push_str(&format!("  Cert directory found: {}\n", path));
8706            }
8707        }
8708    }
8709
8710    Ok(out.trim_end().to_string())
8711}
8712
8713fn inspect_integrity() -> Result<String, String> {
8714    let mut out = String::from("Host inspection: integrity\n\n");
8715
8716    #[cfg(target_os = "windows")]
8717    {
8718        let ps_cmd = "Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing' | Select-Object Corrupt, AutoRepairNeeded, LastRepairAttempted | ConvertTo-Json";
8719        let output = Command::new("powershell")
8720            .args(["-NoProfile", "-Command", &ps_cmd])
8721            .output()
8722            .ok();
8723
8724        if let Some(o) = output {
8725            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8726            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
8727                out.push_str("=== Windows Component Store Health (CBS) ===\n");
8728                let corrupt = val.get("Corrupt").and_then(|v| v.as_u64()).unwrap_or(0);
8729                let repair = val
8730                    .get("AutoRepairNeeded")
8731                    .and_then(|v| v.as_u64())
8732                    .unwrap_or(0);
8733
8734                out.push_str(&format!(
8735                    "  Corruption Detected: {}\n",
8736                    if corrupt != 0 {
8737                        "YES (SFC/DISM recommended)"
8738                    } else {
8739                        "No"
8740                    }
8741                ));
8742                out.push_str(&format!(
8743                    "  Auto-Repair Needed: {}\n",
8744                    if repair != 0 { "YES" } else { "No" }
8745                ));
8746
8747                if let Some(last) = val.get("LastRepairAttempted").and_then(|v| v.as_u64()) {
8748                    out.push_str(&format!("  Last Repair Attempt: (Raw code: {})\n", last));
8749                }
8750            } else {
8751                out.push_str("Could not retrieve CBS health from registry. System may be healthy or state is unknown.\n");
8752            }
8753        }
8754
8755        if Path::new("C:\\Windows\\Logs\\CBS\\CBS.log").exists() {
8756            out.push_str(
8757                "\nNote: Detailed integrity logs available at C:\\Windows\\Logs\\CBS\\CBS.log\n",
8758            );
8759        }
8760    }
8761
8762    #[cfg(not(target_os = "windows"))]
8763    {
8764        out.push_str("System integrity check (Linux)\n\n");
8765        let pkg_check = Command::new("rpm")
8766            .args(["-Va"])
8767            .output()
8768            .or_else(|_| Command::new("dpkg").args(["--verify"]).output())
8769            .ok();
8770        if let Some(o) = pkg_check {
8771            out.push_str("  Package verification system active.\n");
8772            if o.status.success() {
8773                out.push_str("  No major package integrity issues detected.\n");
8774            }
8775        }
8776    }
8777
8778    Ok(out.trim_end().to_string())
8779}
8780
8781fn inspect_domain() -> Result<String, String> {
8782    let mut out = String::from("Host inspection: domain\n\n");
8783
8784    #[cfg(target_os = "windows")]
8785    {
8786        let ps_cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Name, Domain, PartOfDomain, Workgroup | ConvertTo-Json";
8787        let output = Command::new("powershell")
8788            .args(["-NoProfile", "-Command", &ps_cmd])
8789            .output()
8790            .ok();
8791
8792        if let Some(o) = output {
8793            let stdout = String::from_utf8(o.stdout).unwrap_or_default();
8794            if let Ok(val) = serde_json::from_str::<Value>(&stdout) {
8795                let part_of_domain = val
8796                    .get("PartOfDomain")
8797                    .and_then(|v| v.as_bool())
8798                    .unwrap_or(false);
8799                let domain = val
8800                    .get("Domain")
8801                    .and_then(|v| v.as_str())
8802                    .unwrap_or("Unknown");
8803                let workgroup = val
8804                    .get("Workgroup")
8805                    .and_then(|v| v.as_str())
8806                    .unwrap_or("Unknown");
8807
8808                out.push_str("=== Windows Domain / Workgroup Identity ===\n");
8809                out.push_str(&format!(
8810                    "  Join Status: {}\n",
8811                    if part_of_domain {
8812                        "DOMAIN JOINED"
8813                    } else {
8814                        "WORKGROUP"
8815                    }
8816                ));
8817                if part_of_domain {
8818                    out.push_str(&format!("  Active Directory Domain: {}\n", domain));
8819                } else {
8820                    out.push_str(&format!("  Workgroup Name: {}\n", workgroup));
8821                }
8822
8823                if let Some(name) = val.get("Name").and_then(|v| v.as_str()) {
8824                    out.push_str(&format!("  NetBIOS Name: {}\n", name));
8825                }
8826            }
8827        }
8828    }
8829
8830    #[cfg(not(target_os = "windows"))]
8831    {
8832        let domainname = Command::new("domainname")
8833            .output()
8834            .ok()
8835            .and_then(|o| String::from_utf8(o.stdout).ok())
8836            .unwrap_or_default();
8837        out.push_str("=== Linux Domain Identity ===\n");
8838        if !domainname.trim().is_empty() && domainname.trim() != "(none)" {
8839            out.push_str(&format!("  NIS/YP Domain: {}\n", domainname.trim()));
8840        } else {
8841            out.push_str("  No NIS domain configured.\n");
8842        }
8843    }
8844
8845    Ok(out.trim_end().to_string())
8846}
8847
8848fn inspect_device_health() -> Result<String, String> {
8849    let mut out = String::from("Host inspection: device_health\n\n");
8850
8851    #[cfg(target_os = "windows")]
8852    {
8853        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)\" }";
8854        let output = Command::new("powershell")
8855            .args(["-NoProfile", "-Command", ps_cmd])
8856            .output()
8857            .ok()
8858            .and_then(|o| String::from_utf8(o.stdout).ok())
8859            .unwrap_or_default();
8860
8861        if output.trim().is_empty() {
8862            out.push_str("All PnP devices report as healthy (no ConfigManager errors detected).\n");
8863        } else {
8864            out.push_str("=== Malfunctioning Devices (Yellow Bangs) ===\n");
8865            out.push_str(&output);
8866            out.push_str(
8867                "\nTip: Error codes 10 and 28 usually indicate missing or incompatible drivers.\n",
8868            );
8869        }
8870    }
8871
8872    #[cfg(not(target_os = "windows"))]
8873    {
8874        out.push_str("Checking dmesg for hardware errors...\n");
8875        let dmesg = Command::new("dmesg")
8876            .args(["--level=err,crit,alert"])
8877            .output()
8878            .ok()
8879            .and_then(|o| String::from_utf8(o.stdout).ok())
8880            .unwrap_or_default();
8881        if dmesg.is_empty() {
8882            out.push_str("  No critical hardware errors found in dmesg.\n");
8883        } else {
8884            out.push_str(&dmesg.lines().take(20).collect::<Vec<_>>().join("\n"));
8885        }
8886    }
8887
8888    Ok(out.trim_end().to_string())
8889}
8890
8891fn inspect_drivers(max_entries: usize) -> Result<String, String> {
8892    let mut out = String::from("Host inspection: drivers\n\n");
8893
8894    #[cfg(target_os = "windows")]
8895    {
8896        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);
8897        let output = Command::new("powershell")
8898            .args(["-NoProfile", "-Command", &ps_cmd])
8899            .output()
8900            .ok()
8901            .and_then(|o| String::from_utf8(o.stdout).ok())
8902            .unwrap_or_default();
8903
8904        if output.trim().is_empty() {
8905            out.push_str("No drivers retrieved via WMI.\n");
8906        } else {
8907            out.push_str("=== Active System Drivers (CIM Snapshot) ===\n");
8908            out.push_str(&output);
8909        }
8910    }
8911
8912    #[cfg(not(target_os = "windows"))]
8913    {
8914        out.push_str("=== Loaded Kernel Modules (lsmod) ===\n");
8915        let lsmod = Command::new("lsmod")
8916            .output()
8917            .ok()
8918            .and_then(|o| String::from_utf8(o.stdout).ok())
8919            .unwrap_or_default();
8920        out.push_str(
8921            &lsmod
8922                .lines()
8923                .take(max_entries)
8924                .collect::<Vec<_>>()
8925                .join("\n"),
8926        );
8927    }
8928
8929    Ok(out.trim_end().to_string())
8930}
8931
8932fn inspect_peripherals(max_entries: usize) -> Result<String, String> {
8933    let mut out = String::from("Host inspection: peripherals\n\n");
8934
8935    #[cfg(target_os = "windows")]
8936    {
8937        let _ = max_entries;
8938        out.push_str("=== USB Controllers & Hubs ===\n");
8939        let usb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_USBController | ForEach-Object { \"  $($_.Name) ($($_.Status))\" }"])
8940            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8941        out.push_str(if usb.is_empty() {
8942            "  None detected.\n"
8943        } else {
8944            &usb
8945        });
8946
8947        out.push_str("\n=== Input Devices (Keyboard/Pointer) ===\n");
8948        let kb = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_Keyboard | ForEach-Object { \"  [KB] $($_.Name) ($($_.Status))\" }"])
8949            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8950        let mouse = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance Win32_PointingDevice | ForEach-Object { \"  [PTR] $($_.Name) ($($_.Status))\" }"])
8951            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8952        out.push_str(&kb);
8953        out.push_str(&mouse);
8954
8955        out.push_str("\n=== Connected Monitors (WMI) ===\n");
8956        let mon = Command::new("powershell").args(["-NoProfile", "-Command", "Get-CimInstance -Namespace root\\wmi -ClassName WmiMonitorBasicDisplayParams | ForEach-Object { \"  Display ($($_.Active ? 'Active' : 'Inactive'))\" }"])
8957            .output().ok().and_then(|o| String::from_utf8(o.stdout).ok()).unwrap_or_default();
8958        out.push_str(if mon.is_empty() {
8959            "  No active monitors identified via WMI.\n"
8960        } else {
8961            &mon
8962        });
8963    }
8964
8965    #[cfg(not(target_os = "windows"))]
8966    {
8967        out.push_str("=== Connected USB Devices (lsusb) ===\n");
8968        let lsusb = Command::new("lsusb")
8969            .output()
8970            .ok()
8971            .and_then(|o| String::from_utf8(o.stdout).ok())
8972            .unwrap_or_default();
8973        out.push_str(
8974            &lsusb
8975                .lines()
8976                .take(max_entries)
8977                .collect::<Vec<_>>()
8978                .join("\n"),
8979        );
8980    }
8981
8982    Ok(out.trim_end().to_string())
8983}
8984
8985fn inspect_sessions(max_entries: usize) -> Result<String, String> {
8986    let mut out = String::from("Host inspection: sessions\n\n");
8987
8988    #[cfg(target_os = "windows")]
8989    {
8990        let script = r#"Get-CimInstance Win32_LogonSession | ForEach-Object {
8991    "$($_.LogonId)|$($_.StartTime)|$($_.LogonType)|$($_.AuthenticationPackage)"
8992}"#;
8993        if let Ok(o) = Command::new("powershell")
8994            .args(["-NoProfile", "-Command", script])
8995            .output()
8996        {
8997            let text = String::from_utf8_lossy(&o.stdout);
8998            let lines: Vec<&str> = text.lines().collect();
8999            if lines.is_empty() {
9000                out.push_str("No active logon sessions enumerated via WMI.\n");
9001            } else {
9002                out.push_str("=== Active Logon Sessions (WMI Snapshot) ===\n");
9003                for line in lines
9004                    .iter()
9005                    .take(max_entries)
9006                    .filter(|l| !l.trim().is_empty())
9007                {
9008                    let parts: Vec<&str> = line.trim().split('|').collect();
9009                    if parts.len() == 4 {
9010                        let logon_type = match parts[2] {
9011                            "2" => "Interactive",
9012                            "3" => "Network",
9013                            "4" => "Batch",
9014                            "5" => "Service",
9015                            "7" => "Unlock",
9016                            "8" => "NetworkCleartext",
9017                            "9" => "NewCredentials",
9018                            "10" => "RemoteInteractive",
9019                            "11" => "CachedInteractive",
9020                            _ => "Other",
9021                        };
9022                        out.push_str(&format!(
9023                            "- ID: {} | Type: {} | Started: {} | Auth: {}\n",
9024                            parts[0], logon_type, parts[1], parts[3]
9025                        ));
9026                    }
9027                }
9028            }
9029        }
9030    }
9031
9032    #[cfg(not(target_os = "windows"))]
9033    {
9034        out.push_str("=== Logged-in Users (who) ===\n");
9035        let who = Command::new("who")
9036            .output()
9037            .ok()
9038            .and_then(|o| String::from_utf8(o.stdout).ok())
9039            .unwrap_or_default();
9040        out.push_str(&who.lines().take(max_entries).collect::<Vec<_>>().join("\n"));
9041    }
9042
9043    Ok(out.trim_end().to_string())
9044}
9045
9046async fn inspect_disk_benchmark(path: PathBuf) -> Result<String, String> {
9047    let mut out = String::from("Host inspection: disk_benchmark\n\n");
9048    let mut final_path = path;
9049
9050    if !final_path.exists() {
9051        if let Ok(current_exe) = std::env::current_exe() {
9052            out.push_str(&format!(
9053                "Note: Requested target '{}' not found. Falling back to current binary for silicon-aware intensity report.\n",
9054                final_path.display()
9055            ));
9056            final_path = current_exe;
9057        } else {
9058            return Err(format!("Target not found: {}", final_path.display()));
9059        }
9060    }
9061
9062    let target = if final_path.is_dir() {
9063        // Find a representative file to read
9064        let mut target_file = final_path.join("Cargo.toml");
9065        if !target_file.exists() {
9066            target_file = final_path.join("README.md");
9067        }
9068        if !target_file.exists() {
9069            return Err("Target path is a directory but no representative file (Cargo.toml/README.md) found for benchmarking.".to_string());
9070        }
9071        target_file
9072    } else {
9073        final_path
9074    };
9075
9076    out.push_str(&format!("Target: {}\n", target.display()));
9077    out.push_str("Running diagnostic stress test (5s read-thrash + kernel counter trace)...\n\n");
9078
9079    #[cfg(target_os = "windows")]
9080    {
9081        let script = format!(
9082            r#"
9083$target = "{}"
9084if (-not (Test-Path $target)) {{ "ERROR:Target not found"; exit }}
9085
9086$diskQueue = @()
9087$readStats = @()
9088$startTime = Get-Date
9089$duration = 5
9090
9091# Background reader job
9092$job = Start-Job -ScriptBlock {{
9093    param($t, $d)
9094    $stop = (Get-Date).AddSeconds($d)
9095    while ((Get-Date) -lt $stop) {{
9096        try {{ [System.IO.File]::ReadAllBytes($t) | Out-Null }} catch {{ }}
9097    }}
9098}} -ArgumentList $target, $duration
9099
9100# Metrics collector loop
9101$stopTime = (Get-Date).AddSeconds($duration)
9102while ((Get-Date) -lt $stopTime) {{
9103    $q = Get-Counter '\PhysicalDisk(_Total)\Avg. Disk Queue Length' -ErrorAction SilentlyContinue
9104    if ($q) {{ $diskQueue += $q.CounterSamples[0].CookedValue }}
9105    
9106    $r = Get-Counter '\PhysicalDisk(_Total)\Disk Reads/sec' -ErrorAction SilentlyContinue
9107    if ($r) {{ $readStats += $r.CounterSamples[0].CookedValue }}
9108    
9109    Start-Sleep -Milliseconds 250
9110}}
9111
9112Stop-Job $job
9113Receive-Job $job | Out-Null
9114Remove-Job $job
9115
9116$avgQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Average).Average }} else {{ 0 }}
9117$maxQ = if ($diskQueue) {{ ($diskQueue | Measure-Object -Maximum).Maximum }} else {{ 0 }}
9118$avgR = if ($readStats) {{ ($readStats | Measure-Object -Average).Average }} else {{ 0 }}
9119
9120"AVG_Q:$([math]::Round($avgQ, 4))|MAX_Q:$([math]::Round($maxQ, 4))|AVG_R:$([math]::Round($avgR, 2))"
9121"#,
9122            target.display()
9123        );
9124
9125        let output = Command::new("powershell")
9126            .args(["-NoProfile", "-Command", &script])
9127            .output()
9128            .map_err(|e| format!("Benchmark failed: {e}"))?;
9129
9130        let raw = String::from_utf8_lossy(&output.stdout);
9131        let text = raw.trim();
9132
9133        if text.starts_with("ERROR") {
9134            return Err(text.to_string());
9135        }
9136
9137        let mut lines = text.lines();
9138        if let Some(metrics_line) = lines.next() {
9139            let parts: Vec<&str> = metrics_line.split('|').collect();
9140            let mut avg_q = "unknown".to_string();
9141            let mut max_q = "unknown".to_string();
9142            let mut avg_r = "unknown".to_string();
9143
9144            for p in parts {
9145                if let Some((k, v)) = p.split_once(':') {
9146                    match k {
9147                        "AVG_Q" => avg_q = v.to_string(),
9148                        "MAX_Q" => max_q = v.to_string(),
9149                        "AVG_R" => avg_r = v.to_string(),
9150                        _ => {}
9151                    }
9152                }
9153            }
9154
9155            out.push_str("=== WORKSTATION INTENSITY REPORT ===\n");
9156            out.push_str(&format!("- Active Disk Queue (Avg): {}\n", avg_q));
9157            out.push_str(&format!("- Active Disk Queue (Max): {}\n", max_q));
9158            out.push_str(&format!("- Disk Throughput (Avg):  {} reads/sec\n", avg_r));
9159            out.push_str("\nVerdict: ");
9160            let q_num = avg_q.parse::<f64>().unwrap_or(0.0);
9161            if q_num > 1.0 {
9162                out.push_str(
9163                    "HIGH INTENSITY — the disk stack is saturated. Hardware bottleneck confirmed.",
9164                );
9165            } else if q_num > 0.1 {
9166                out.push_str("MODERATE LOAD — significant I/O pressure detected.");
9167            } else {
9168                out.push_str("LIGHT LOAD — the hardware is handling this volume comfortably.");
9169            }
9170        }
9171    }
9172
9173    #[cfg(not(target_os = "windows"))]
9174    {
9175        out.push_str("Note: Native silicon benchmarking is currently optimized for Windows performance counters.\n");
9176        out.push_str("Generic disk load simulated.\n");
9177    }
9178
9179    Ok(out)
9180}
9181
9182fn inspect_permissions(path: PathBuf, _max_entries: usize) -> Result<String, String> {
9183    let mut out = String::from("Host inspection: permissions\n\n");
9184    out.push_str(&format!("Auditing access control for: {}\n\n", path.display()));
9185
9186    #[cfg(target_os = "windows")]
9187    {
9188        let script = format!(
9189            "Get-Acl -Path '{}' | Select-Object Owner, AccessToString | ForEach-Object {{ \"OWNER:$($_.Owner)\"; \"RULES:$($_.AccessToString)\" }}",
9190            path.display()
9191        );
9192        let output = Command::new("powershell")
9193            .args(["-NoProfile", "-Command", &script])
9194            .output()
9195            .map_err(|e| format!("ACL check failed: {e}"))?;
9196
9197        let text = String::from_utf8_lossy(&output.stdout);
9198        if text.trim().is_empty() {
9199            out.push_str("No ACL information returned. Ensure the path exists and you have permission to query it.\n");
9200        } else {
9201            out.push_str("=== Windows NTFS Permissions ===\n");
9202            out.push_str(&text);
9203        }
9204    }
9205
9206    #[cfg(not(target_os = "windows"))]
9207    {
9208        let output = Command::new("ls")
9209            .args(["-ld", &path.to_string_lossy()])
9210            .output()
9211            .map_err(|e| format!("ls check failed: {e}"))?;
9212        out.push_str("=== Unix File Permissions ===\n");
9213        out.push_str(&String::from_utf8_lossy(&output.stdout));
9214    }
9215
9216    Ok(out.trim_end().to_string())
9217}
9218
9219fn inspect_login_history(max_entries: usize) -> Result<String, String> {
9220    let mut out = String::from("Host inspection: login_history\n\n");
9221
9222    #[cfg(target_os = "windows")]
9223    {
9224        out.push_str("Checking recent Logon events (Event ID 4624) from the Security Log...\n");
9225        out.push_str("Note: This typically requires Administrator elevation.\n\n");
9226
9227        let n = max_entries.clamp(1, 50);
9228        let script = format!(
9229            r#"try {{
9230    $events = Get-WinEvent -FilterHashtable @{{LogName='Security'; ID=4624}} -MaxEvents {n} -ErrorAction Stop
9231    $events | ForEach-Object {{
9232        $time = $_.TimeCreated.ToString('yyyy-MM-dd HH:mm')
9233        # Extract target user name from the XML/Properties if possible
9234        $user = $_.Properties[5].Value
9235        $type = $_.Properties[8].Value
9236        "[$time] User: $user | Type: $type"
9237    }}
9238}} catch {{ "ERROR:" + $_.Exception.Message }}"#
9239        );
9240
9241        let output = Command::new("powershell")
9242            .args(["-NoProfile", "-Command", &script])
9243            .output()
9244            .map_err(|e| format!("Login history query failed: {e}"))?;
9245
9246        let text = String::from_utf8_lossy(&output.stdout);
9247        if text.starts_with("ERROR:") {
9248            out.push_str(&format!("Unable to query Security Log: {}\n", text));
9249        } else if text.trim().is_empty() {
9250            out.push_str("No recent logon events found or access denied.\n");
9251        } else {
9252            out.push_str("=== Recent Logons (Event ID 4624) ===\n");
9253            out.push_str(&text);
9254        }
9255    }
9256
9257    #[cfg(not(target_os = "windows"))]
9258    {
9259        let output = Command::new("last")
9260            .args(["-n", &max_entries.to_string()])
9261            .output()
9262            .map_err(|e| format!("last command failed: {e}"))?;
9263        out.push_str("=== Unix Login History (last) ===\n");
9264        out.push_str(&String::from_utf8_lossy(&output.stdout));
9265    }
9266
9267    Ok(out.trim_end().to_string())
9268}
9269
9270fn inspect_share_access(path: PathBuf) -> Result<String, String> {
9271    let mut out = String::from("Host inspection: share_access\n\n");
9272    out.push_str(&format!("Testing accessibility of: {}\n\n", path.display()));
9273
9274    #[cfg(target_os = "windows")]
9275    {
9276        let script = format!(
9277            r#"
9278$p = '{}'
9279$res = @{{ Reachable = $false; Readable = $false; Error = "" }}
9280if (Test-Connection -ComputerName ($p.Split('\')[2]) -Count 1 -Quiet -ErrorAction SilentlyContinue) {{
9281    $res.Reachable = $true
9282    try {{
9283        $null = Get-ChildItem -Path $p -ErrorAction Stop
9284        $res.Readable = $true
9285    }} catch {{
9286        $res.Error = $_.Exception.Message
9287    }}
9288}} else {{
9289    $res.Error = "Server unreachable (Ping failed)"
9290}}
9291"REACHABLE:$($res.Reachable)|READABLE:$($res.Readable)|ERROR:$($res.Error)""#,
9292            path.display()
9293        );
9294
9295        let output = Command::new("powershell")
9296            .args(["-NoProfile", "-Command", &script])
9297            .output()
9298            .map_err(|e| format!("Share test failed: {e}"))?;
9299
9300        let text = String::from_utf8_lossy(&output.stdout);
9301        out.push_str("=== Share Triage Results ===\n");
9302        out.push_str(&text);
9303    }
9304
9305    #[cfg(not(target_os = "windows"))]
9306    {
9307        out.push_str("Share access testing is primarily optimized for Windows UNC paths.\n");
9308    }
9309
9310    Ok(out.trim_end().to_string())
9311}
9312
9313fn inspect_dns_fix_plan(issue: &str) -> Result<String, String> {
9314    let mut out = String::from("Host inspection: fix_plan (DNS Resolution)\n\n");
9315    out.push_str(&format!("Issue: {}\n\n", issue));
9316    out.push_str("Proposed Remediation Steps:\n");
9317    out.push_str("1. **Flush DNS Cache**: Clear local resolver cache.\n");
9318    out.push_str("   `ipconfig /flushdns` (Windows) or `sudo resolvectl flush-caches` (Linux)\n");
9319    out.push_str("2. **Validate Hosts File**: Check for static overrides.\n");
9320    out.push_str("   `Get-Content C:\\Windows\\System32\\drivers\\etc\\hosts` (Windows)\n");
9321    out.push_str("3. **Test Name Resolution**: Use nslookup to query a specific server.\n");
9322    out.push_str("   `nslookup google.com 8.8.8.8` (Tests if external DNS works)\n");
9323    out.push_str("4. **Check Adapter DNS**: Ensure local settings match expected nameservers.\n");
9324    out.push_str("   `Get-NetIPConfiguration | Select-Object InterfaceAlias, DNSServer` (Windows)\n");
9325    
9326    Ok(out)
9327}
9328
9329fn inspect_registry_audit() -> Result<String, String> {
9330    let mut out = String::from("Host inspection: registry_audit\n\n");
9331    out.push_str("Auditing advanced persistence points and shell integrity overrides...\n\n");
9332
9333    #[cfg(target_os = "windows")]
9334    {
9335        let script = r#"
9336$findings = @()
9337
9338# 1. Image File Execution Options (Debugger Hijacking)
9339$ifeo = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options"
9340if (Test-Path $ifeo) {
9341    Get-ChildItem $ifeo | ForEach-Object {
9342        $p = Get-ItemProperty $_.PSPath
9343        if ($p.debugger) { $findings += "[IFEO Hijack] $($_.PSChildName) -> Debugger defined: $($p.debugger)" }
9344    }
9345}
9346
9347# 2. Winlogon Shell Integrity
9348$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
9349$shell = (Get-ItemProperty $winlogon -Name Shell -ErrorAction SilentlyContinue).Shell
9350if ($shell -and $shell -ne "explorer.exe") {
9351    $findings += "[Winlogon Overlook] Non-standard shell defined: $shell"
9352}
9353
9354# 3. Session Manager BootExecute
9355$sm = "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager"
9356$boot = (Get-ItemProperty $sm -Name BootExecute -ErrorAction SilentlyContinue).BootExecute
9357if ($boot -and $boot -notcontains "autocheck autochk *") {
9358    $findings += "[Boot Integrity] Non-standard BootExecute defined: $($boot -join ', ')"
9359}
9360
9361if ($findings.Count -eq 0) {
9362    "PASS: No common registry hijacking or shell overrides detected."
9363} else {
9364    $findings -join "`n"
9365}
9366"#;
9367        let output = Command::new("powershell")
9368            .args(["-NoProfile", "-Command", &script])
9369            .output()
9370            .map_err(|e| format!("Registry audit failed: {e}"))?;
9371
9372        let text = String::from_utf8_lossy(&output.stdout);
9373        out.push_str("=== Persistence & Integrity Check ===\n");
9374        out.push_str(&text);
9375    }
9376
9377    #[cfg(not(target_os = "windows"))]
9378    {
9379        out.push_str("Registry auditing is specific to Windows environments.\n");
9380    }
9381
9382    Ok(out.trim_end().to_string())
9383}
9384
9385fn inspect_thermal() -> Result<String, String> {
9386    let mut out = String::from("Host inspection: thermal\n\n");
9387    out.push_str("Checking CPU thermal state and active throttling indicators...\n\n");
9388
9389    #[cfg(target_os = "windows")]
9390    {
9391        let script = r#"
9392$thermal = Get-CimInstance -ClassName Win32_PerfRawData_Counters_ThermalZoneInformation -ErrorAction SilentlyContinue
9393if ($thermal) {
9394    $thermal | ForEach-Object {
9395        $temp = [math]::Round(($_.Temperature - 273.15), 1)
9396        "Zone: $($_.Name) | Temp: $temp °C | Throttling: $($_.HighPrecisionTemperature -eq 0 ? 'NO' : 'ACTIVE')"
9397    }
9398} else {
9399    "Thermal counters not directly available via WMI. Checking for system throttling indicators..."
9400    $throttling = Get-CimInstance -ClassName Win32_Processor | Select-Object -ExpandProperty LoadPercentage
9401    "Current CPU Load: $throttling%"
9402}
9403"#;
9404        let output = Command::new("powershell")
9405            .args(["-NoProfile", "-Command", script])
9406            .output()
9407            .map_err(|e| format!("Thermal check failed: {e}"))?;
9408        out.push_str("=== Windows Thermal State ===\n");
9409        out.push_str(&String::from_utf8_lossy(&output.stdout));
9410    }
9411
9412    #[cfg(not(target_os = "windows"))]
9413    {
9414        out.push_str("Thermal inspection is currently optimized for Windows performance counters.\n");
9415    }
9416
9417    Ok(out.trim_end().to_string())
9418}
9419
9420fn inspect_activation() -> Result<String, String> {
9421    let mut out = String::from("Host inspection: activation\n\n");
9422    out.push_str("Auditing Windows activation and license state...\n\n");
9423
9424    #[cfg(target_os = "windows")]
9425    {
9426        let script = r#"
9427$xpr = cscript //nologo C:\Windows\System32\slmgr.vbs /xpr
9428$dli = cscript //nologo C:\Windows\System32\slmgr.vbs /dli
9429"Status: $($xpr.Trim())"
9430"Details: $($dli -join ' ' | Select-String -Pattern 'License Status|Name' -AllMatches | ForEach-Object { $_.ToString().Trim() })"
9431"#;
9432        let output = Command::new("powershell")
9433            .args(["-NoProfile", "-Command", script])
9434            .output()
9435            .map_err(|e| format!("Activation check failed: {e}"))?;
9436        out.push_str("=== Windows License Report ===\n");
9437        out.push_str(&String::from_utf8_lossy(&output.stdout));
9438    }
9439
9440    #[cfg(not(target_os = "windows"))]
9441    {
9442        out.push_str("Windows activation check is specific to the Windows platform.\n");
9443    }
9444
9445    Ok(out.trim_end().to_string())
9446}
9447
9448fn inspect_patch_history(max_entries: usize) -> Result<String, String> {
9449    let mut out = String::from("Host inspection: patch_history\n\n");
9450    out.push_str(&format!("Listing the last {} installed Windows updates (KBs)...\n\n", max_entries));
9451
9452    #[cfg(target_os = "windows")]
9453    {
9454        let n = max_entries.clamp(1, 50);
9455        let script = format!(
9456            "Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First {} | ForEach-Object {{ \"[$($_.InstalledOn.ToString('yyyy-MM-dd'))] $($_.HotFixID) - $($_.Description)\" }}",
9457            n
9458        );
9459        let output = Command::new("powershell")
9460            .args(["-NoProfile", "-Command", &script])
9461            .output()
9462            .map_err(|e| format!("Patch history query failed: {e}"))?;
9463        out.push_str("=== Recent HotFixes (KBs) ===\n");
9464        out.push_str(&String::from_utf8_lossy(&output.stdout));
9465    }
9466
9467    #[cfg(not(target_os = "windows"))]
9468    {
9469        out.push_str("Patch history is currently focused on Windows HotFixes.\n");
9470    }
9471
9472    Ok(out.trim_end().to_string())
9473}
9474
9475// ── ad_user ──────────────────────────────────────────────────────────────────
9476
9477fn inspect_ad_user(identity: &str) -> Result<String, String> {
9478    let mut out = String::from("Host inspection: ad_user\n\n");
9479    let ident = identity.trim();
9480    if ident.is_empty() {
9481        out.push_str("Status: No identity specified. Performing self-discovery...\n");
9482        #[cfg(target_os = "windows")]
9483        {
9484            let script = r#"
9485$u = [System.Security.Principal.WindowsIdentity]::GetCurrent()
9486"USER: " + $u.Name
9487"SID: " + $u.User.Value
9488"GROUPS: " + (($u.Groups | ForEach-Object { try { $_.Translate([System.Security.Principal.NTAccount]).Value } catch { $_.Value } }) -join ', ')
9489"ELEVATED: " + (New-Object System.Security.Principal.WindowsPrincipal($u)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
9490"#;
9491            let output = Command::new("powershell")
9492                .args(["-NoProfile", "-Command", script])
9493                .output()
9494                .ok();
9495            if let Some(o) = output {
9496                out.push_str(&String::from_utf8_lossy(&o.stdout));
9497            }
9498        }
9499        return Ok(out);
9500    }
9501
9502    #[cfg(target_os = "windows")]
9503    {
9504        let script = format!(
9505            r#"
9506try {{
9507    $u = Get-ADUser -Identity "{ident}" -Properties MemberOf, LastLogonDate, Enabled, PasswordExpired -ErrorAction Stop
9508    "NAME: " + $u.Name
9509    "SID: " + $u.SID
9510    "ENABLED: " + $u.Enabled
9511    "EXPIRED: " + $u.PasswordExpired
9512    "LOGON: " + $u.LastLogonDate
9513    "GROUPS: " + ($u.MemberOf -replace "CN=([^,]+),.*", "$1" -join ", ")
9514}} catch {{
9515    # Fallback to net user if AD module is missing or fails
9516    $net = net user "{ident}" /domain 2>&1
9517    if ($LASTEXITCODE -eq 0) {{
9518        $net | Select-String "User name", "Full Name", "Account active", "Password expires", "Last logon", "Local Group Memberships", "Global Group memberships" | ForEach-Object {{ $_.ToString().Trim() }}
9519    }} else {{
9520        "ERROR: " + $_.Exception.Message
9521    }}
9522}}"#
9523        );
9524
9525        let output = Command::new("powershell")
9526            .args(["-NoProfile", "-Command", &script])
9527            .output()
9528            .ok();
9529
9530        if let Some(o) = output {
9531            let stdout = String::from_utf8_lossy(&o.stdout);
9532            if stdout.contains("ERROR:") && stdout.contains("Get-ADUser") {
9533                out.push_str("Active Directory PowerShell module not found. Showing basic domain user info:\n\n");
9534            }
9535            out.push_str(&stdout);
9536        }
9537    }
9538
9539    #[cfg(not(target_os = "windows"))]
9540    {
9541        let _ = ident;
9542        out.push_str("(AD User lookup only available on Windows nodes)\n");
9543    }
9544
9545    Ok(out.trim_end().to_string())
9546}
9547
9548// ── dns_lookup ───────────────────────────────────────────────────────────────
9549
9550fn inspect_dns_lookup(name: &str, record_type: &str) -> Result<String, String> {
9551    let mut out = String::from("Host inspection: dns_lookup\n\n");
9552    let target = name.trim();
9553    if target.is_empty() {
9554        return Err("Missing required target name for dns_lookup.".to_string());
9555    }
9556
9557    #[cfg(target_os = "windows")]
9558    {
9559        let script = format!("Resolve-DnsName -Name '{target}' -Type {record_type} -ErrorAction SilentlyContinue | Select-Object Name, Type, TTL, Section, NameHost, Strings, IPAddress, Address | Format-List");
9560        let output = Command::new("powershell")
9561            .args(["-NoProfile", "-Command", &script])
9562            .output()
9563            .ok();
9564        if let Some(o) = output {
9565            let stdout = String::from_utf8_lossy(&o.stdout);
9566            if stdout.trim().is_empty() {
9567                out.push_str(&format!("No {record_type} records found for {target}.\n"));
9568            } else {
9569                out.push_str(&stdout);
9570            }
9571        }
9572    }
9573
9574    #[cfg(not(target_os = "windows"))]
9575    {
9576        let output = Command::new("dig")
9577            .args([target, record_type, "+short"])
9578            .output()
9579            .ok();
9580        if let Some(o) = output {
9581            out.push_str(&String::from_utf8_lossy(&o.stdout));
9582        }
9583    }
9584
9585    Ok(out.trim_end().to_string())
9586}
9587
9588// ── hyperv ───────────────────────────────────────────────────────────────────
9589
9590fn inspect_hyperv() -> Result<String, String> {
9591    let mut out = String::from("Host inspection: hyperv\n\n");
9592
9593    #[cfg(target_os = "windows")]
9594    {
9595        let script = "Get-VM -ErrorAction SilentlyContinue | Select-Object Name, State, Uptime, Status, CPUUsage, MemoryAssigned | Format-Table -AutoSize";
9596        let output = Command::new("powershell")
9597            .args(["-NoProfile", "-Command", script])
9598            .output()
9599            .ok();
9600        if let Some(o) = output {
9601            let stdout = String::from_utf8_lossy(&o.stdout);
9602            if stdout.trim().is_empty() {
9603                out.push_str("No Hyper-V Virtual Machines found or Hyper-V module not installed.\n");
9604            } else {
9605                out.push_str(&stdout);
9606            }
9607        }
9608    }
9609
9610    #[cfg(not(target_os = "windows"))]
9611    {
9612        out.push_str("(Hyper-V lookup only available on Windows hosts)\n");
9613    }
9614
9615    Ok(out.trim_end().to_string())
9616}
9617
9618// ── ip_config ────────────────────────────────────────────────────────────────
9619
9620fn inspect_ip_config() -> Result<String, String> {
9621    let mut out = String::from("Host inspection: ip_config\n\n");
9622
9623    #[cfg(target_os = "windows")]
9624    {
9625        let script = "Get-NetIPConfiguration -Detailed | ForEach-Object { \
9626            $_.InterfaceAlias + ' [' + $_.InterfaceDescription + ']' + \
9627            '\\n  Status: ' + $_.NetAdapter.Status + \
9628            '\\n  Initial IPv4: ' + ($_.IPv4Address.IPAddress -join ', ') + \
9629            '\\n  DHCP Enabled: ' + $_.NetAdapter.DhcpStatus + \
9630            '\\n  DHCP Server: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
9631            '\\n  IPv4 Default Gateway: ' + ($_.IPv4DefaultGateway.NextHop -join ', ') + \
9632            '\\n  DNSServer: ' + ($_.DNSServer.ServerAddresses -join ', ') + '\\n' \
9633        }";
9634        let output = Command::new("powershell")
9635            .args(["-NoProfile", "-Command", script])
9636            .output()
9637            .ok();
9638        if let Some(o) = output {
9639            out.push_str(&String::from_utf8_lossy(&o.stdout));
9640        }
9641    }
9642
9643    #[cfg(not(target_os = "windows"))]
9644    {
9645        let output = Command::new("ip").args(["addr", "show"]).output().ok();
9646        if let Some(o) = output {
9647            out.push_str(&String::from_utf8_lossy(&o.stdout));
9648        }
9649    }
9650
9651    Ok(out.trim_end().to_string())
9652}