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 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 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 #[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 #[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 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 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 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 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 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
3294fn 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
3595fn 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 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 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 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
3714fn 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 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 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 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 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 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 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 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 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 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 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
4010fn inspect_storage(max_entries: usize) -> Result<String, String> {
4013 let mut out = String::from("Host inspection: storage\n\n");
4014 let _ = max_entries; 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 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 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 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
4203fn inspect_hardware() -> Result<String, String> {
4206 let mut out = String::from("Host inspection: hardware\n\n");
4207
4208 #[cfg(target_os = "windows")]
4209 {
4210 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 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 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 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 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 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 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 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 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
4395fn inspect_updates() -> Result<String, String> {
4398 let mut out = String::from("Host inspection: updates\n\n");
4399
4400 #[cfg(target_os = "windows")]
4401 {
4402 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 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 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
4519fn inspect_security() -> Result<String, String> {
4522 let mut out = String::from("Host inspection: security\n\n");
4523
4524 #[cfg(target_os = "windows")]
4525 {
4526 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 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 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 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 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
4682fn 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
4742fn 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 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
4863fn 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
4994fn 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 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 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
5117fn 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
5221fn 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 {
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 {
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 {
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 #[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 {
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 {
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 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 ¬es {
5489 out.push_str(&format!(" [-] {n}\n"));
5490 }
5491 }
5492 }
5493
5494 Ok(out.trim_end().to_string())
5495}
5496
5497fn 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
5594fn 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
5671fn 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
5757fn 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 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
5862fn 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
5984fn 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
6077fn 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
6135fn 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
6215fn 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
6273fn 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
6366fn 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
6543fn 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
6629fn 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
6770fn 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
6855fn 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
7034fn 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 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
7223fn 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
7373fn 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: &[], default_port: 0, 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 fn port_listening(port: u16) -> bool {
7478 if port == 0 {
7479 return false;
7480 }
7481 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 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 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 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 #[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 #[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
7597fn 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
7715fn 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
7816fn 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
7921fn 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 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 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
9475fn 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
9548fn 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
9588fn 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
9618fn 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}