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