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 "repo_doctor" => {
35 let path = resolve_optional_path(args)?;
36 inspect_repo_doctor(path, max_entries)
37 }
38 "directory" => {
39 let raw_path = args
40 .get("path")
41 .and_then(|v| v.as_str())
42 .ok_or_else(|| {
43 "Missing required argument: 'path' for inspect_host(topic: \"directory\")"
44 .to_string()
45 })?;
46 let resolved = resolve_path(raw_path)?;
47 inspect_directory("Directory", resolved, max_entries).await
48 }
49 other => Err(format!(
50 "Unknown inspect_host topic '{}'. Use one of: summary, toolchains, path, env_doctor, fix_plan, network, services, processes, desktop, downloads, directory, disk, ports, repo_doctor.",
51 other
52 )),
53 }
54}
55
56fn parse_max_entries(args: &Value) -> usize {
57 args.get("max_entries")
58 .and_then(|v| v.as_u64())
59 .map(|n| n as usize)
60 .unwrap_or(DEFAULT_MAX_ENTRIES)
61 .clamp(1, MAX_ENTRIES_CAP)
62}
63
64fn parse_port_filter(args: &Value) -> Option<u16> {
65 args.get("port")
66 .and_then(|v| v.as_u64())
67 .and_then(|n| u16::try_from(n).ok())
68}
69
70fn parse_name_filter(args: &Value) -> Option<String> {
71 args.get("name")
72 .and_then(|v| v.as_str())
73 .map(str::trim)
74 .filter(|value| !value.is_empty())
75 .map(|value| value.to_string())
76}
77
78fn parse_issue_text(args: &Value) -> Option<String> {
79 args.get("issue")
80 .and_then(|v| v.as_str())
81 .map(str::trim)
82 .filter(|value| !value.is_empty())
83 .map(|value| value.to_string())
84}
85
86fn resolve_optional_path(args: &Value) -> Result<PathBuf, String> {
87 match args.get("path").and_then(|v| v.as_str()) {
88 Some(raw_path) => resolve_path(raw_path),
89 None => {
90 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))
91 }
92 }
93}
94
95fn inspect_summary(max_entries: usize) -> Result<String, String> {
96 let current_dir =
97 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
98 let workspace_root = crate::tools::file_ops::workspace_root();
99 let workspace_mode = workspace_mode_label(&workspace_root);
100 let path_stats = analyze_path_env();
101 let toolchains = collect_toolchains();
102
103 let mut out = String::from("Host inspection: summary\n\n");
104 out.push_str(&format!("- OS: {}\n", std::env::consts::OS));
105 out.push_str(&format!("- Current directory: {}\n", current_dir.display()));
106 out.push_str(&format!("- Workspace root: {}\n", workspace_root.display()));
107 out.push_str(&format!("- Workspace mode: {}\n", workspace_mode));
108 out.push_str(&format!("- Preferred shell: {}\n", preferred_shell_label()));
109 out.push_str(&format!(
110 "- PATH entries: {} total, {} unique, {} duplicates, {} missing\n",
111 path_stats.total_entries,
112 path_stats.unique_entries,
113 path_stats.duplicate_entries.len(),
114 path_stats.missing_entries.len()
115 ));
116
117 if toolchains.found.is_empty() {
118 out.push_str(
119 "- Toolchains found: none of the common developer tools were detected on PATH\n",
120 );
121 } else {
122 out.push_str("- Toolchains found:\n");
123 for (label, version) in toolchains.found.iter().take(max_entries.min(8)) {
124 out.push_str(&format!(" - {}: {}\n", label, version));
125 }
126 if toolchains.found.len() > max_entries.min(8) {
127 out.push_str(&format!(
128 " - ... {} more found tools omitted\n",
129 toolchains.found.len() - max_entries.min(8)
130 ));
131 }
132 }
133
134 if !toolchains.missing.is_empty() {
135 out.push_str(&format!(
136 "- Common tools not detected on PATH: {}\n",
137 toolchains.missing.join(", ")
138 ));
139 }
140
141 for (label, path) in [("Desktop", desktop_dir()), ("Downloads", downloads_dir())] {
142 match path {
143 Some(path) if path.exists() => match count_top_level_items(&path) {
144 Ok(count) => out.push_str(&format!(
145 "- {}: {} top-level items at {}\n",
146 label,
147 count,
148 path.display()
149 )),
150 Err(e) => out.push_str(&format!(
151 "- {}: exists at {} but could not inspect ({})\n",
152 label,
153 path.display(),
154 e
155 )),
156 },
157 Some(path) => out.push_str(&format!(
158 "- {}: expected at {} but not found\n",
159 label,
160 path.display()
161 )),
162 None => out.push_str(&format!("- {}: location unavailable on this host\n", label)),
163 }
164 }
165
166 Ok(out.trim_end().to_string())
167}
168
169fn inspect_toolchains() -> Result<String, String> {
170 let report = collect_toolchains();
171 let mut out = String::from("Host inspection: toolchains\n\n");
172
173 if report.found.is_empty() {
174 out.push_str("- No common developer tools were detected on PATH.");
175 } else {
176 out.push_str("Detected developer tools:\n");
177 for (label, version) in report.found {
178 out.push_str(&format!("- {}: {}\n", label, version));
179 }
180 }
181
182 if !report.missing.is_empty() {
183 out.push_str("\nNot detected on PATH:\n");
184 for label in report.missing {
185 out.push_str(&format!("- {}\n", label));
186 }
187 }
188
189 Ok(out.trim_end().to_string())
190}
191
192fn inspect_path(max_entries: usize) -> Result<String, String> {
193 let path_stats = analyze_path_env();
194 let mut out = String::from("Host inspection: PATH\n\n");
195 out.push_str(&format!("- Total entries: {}\n", path_stats.total_entries));
196 out.push_str(&format!(
197 "- Unique entries: {}\n",
198 path_stats.unique_entries
199 ));
200 out.push_str(&format!(
201 "- Duplicate entries: {}\n",
202 path_stats.duplicate_entries.len()
203 ));
204 out.push_str(&format!(
205 "- Missing paths: {}\n",
206 path_stats.missing_entries.len()
207 ));
208
209 out.push_str("\nPATH entries:\n");
210 for entry in path_stats.entries.iter().take(max_entries) {
211 out.push_str(&format!("- {}\n", entry));
212 }
213 if path_stats.entries.len() > max_entries {
214 out.push_str(&format!(
215 "- ... {} more entries omitted\n",
216 path_stats.entries.len() - max_entries
217 ));
218 }
219
220 if !path_stats.duplicate_entries.is_empty() {
221 out.push_str("\nDuplicate entries:\n");
222 for entry in path_stats.duplicate_entries.iter().take(max_entries) {
223 out.push_str(&format!("- {}\n", entry));
224 }
225 if path_stats.duplicate_entries.len() > max_entries {
226 out.push_str(&format!(
227 "- ... {} more duplicates omitted\n",
228 path_stats.duplicate_entries.len() - max_entries
229 ));
230 }
231 }
232
233 if !path_stats.missing_entries.is_empty() {
234 out.push_str("\nMissing directories:\n");
235 for entry in path_stats.missing_entries.iter().take(max_entries) {
236 out.push_str(&format!("- {}\n", entry));
237 }
238 if path_stats.missing_entries.len() > max_entries {
239 out.push_str(&format!(
240 "- ... {} more missing entries omitted\n",
241 path_stats.missing_entries.len() - max_entries
242 ));
243 }
244 }
245
246 Ok(out.trim_end().to_string())
247}
248
249fn inspect_env_doctor(max_entries: usize) -> Result<String, String> {
250 let path_stats = analyze_path_env();
251 let toolchains = collect_toolchains();
252 let package_managers = collect_package_managers();
253 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
254
255 let mut out = String::from("Host inspection: env_doctor\n\n");
256 out.push_str(&format!(
257 "- PATH health: {} duplicates, {} missing entries\n",
258 path_stats.duplicate_entries.len(),
259 path_stats.missing_entries.len()
260 ));
261 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
262 out.push_str(&format!(
263 "- Package managers found: {}\n",
264 package_managers.found.len()
265 ));
266
267 if !package_managers.found.is_empty() {
268 out.push_str("\nPackage managers:\n");
269 for (label, version) in package_managers.found.iter().take(max_entries) {
270 out.push_str(&format!("- {}: {}\n", label, version));
271 }
272 if package_managers.found.len() > max_entries {
273 out.push_str(&format!(
274 "- ... {} more package managers omitted\n",
275 package_managers.found.len() - max_entries
276 ));
277 }
278 }
279
280 if !path_stats.duplicate_entries.is_empty() {
281 out.push_str("\nDuplicate PATH entries:\n");
282 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
283 out.push_str(&format!("- {}\n", entry));
284 }
285 if path_stats.duplicate_entries.len() > max_entries.min(5) {
286 out.push_str(&format!(
287 "- ... {} more duplicate entries omitted\n",
288 path_stats.duplicate_entries.len() - max_entries.min(5)
289 ));
290 }
291 }
292
293 if !path_stats.missing_entries.is_empty() {
294 out.push_str("\nMissing PATH entries:\n");
295 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
296 out.push_str(&format!("- {}\n", entry));
297 }
298 if path_stats.missing_entries.len() > max_entries.min(5) {
299 out.push_str(&format!(
300 "- ... {} more missing entries omitted\n",
301 path_stats.missing_entries.len() - max_entries.min(5)
302 ));
303 }
304 }
305
306 if !findings.is_empty() {
307 out.push_str("\nFindings:\n");
308 for finding in findings.iter().take(max_entries.max(5)) {
309 out.push_str(&format!("- {}\n", finding));
310 }
311 if findings.len() > max_entries.max(5) {
312 out.push_str(&format!(
313 "- ... {} more findings omitted\n",
314 findings.len() - max_entries.max(5)
315 ));
316 }
317 } else {
318 out.push_str("\nFindings:\n- No obvious environment drift was detected from PATH and package-manager checks.");
319 }
320
321 out.push_str(
322 "\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.",
323 );
324
325 Ok(out.trim_end().to_string())
326}
327
328#[derive(Clone, Copy, Debug, Eq, PartialEq)]
329enum FixPlanKind {
330 EnvPath,
331 PortConflict,
332 LmStudio,
333 Generic,
334}
335
336async fn inspect_fix_plan(
337 issue: Option<String>,
338 port_filter: Option<u16>,
339 max_entries: usize,
340) -> Result<String, String> {
341 let issue = issue.unwrap_or_else(|| {
342 "Help me fix PATH, toolchain, port-conflict, or LM Studio connectivity problems."
343 .to_string()
344 });
345 let plan_kind = classify_fix_plan_kind(&issue, port_filter);
346 match plan_kind {
347 FixPlanKind::EnvPath => inspect_env_fix_plan(&issue, max_entries),
348 FixPlanKind::PortConflict => inspect_port_fix_plan(&issue, port_filter, max_entries),
349 FixPlanKind::LmStudio => inspect_lm_studio_fix_plan(&issue, max_entries).await,
350 FixPlanKind::Generic => inspect_generic_fix_plan(&issue),
351 }
352}
353
354fn classify_fix_plan_kind(issue: &str, port_filter: Option<u16>) -> FixPlanKind {
355 let lower = issue.to_ascii_lowercase();
356 if port_filter.is_some()
357 || lower.contains("port ")
358 || lower.contains("address already in use")
359 || lower.contains("already in use")
360 || lower.contains("what owns port")
361 || lower.contains("listening on port")
362 {
363 FixPlanKind::PortConflict
364 } else if lower.contains("lm studio")
365 || lower.contains("localhost:1234")
366 || lower.contains("/v1/models")
367 || lower.contains("no coding model loaded")
368 || lower.contains("embedding model")
369 || lower.contains("server on port 1234")
370 || lower.contains("runtime refresh")
371 {
372 FixPlanKind::LmStudio
373 } else if lower.contains("cargo")
374 || lower.contains("rustc")
375 || lower.contains("path")
376 || lower.contains("package manager")
377 || lower.contains("package managers")
378 || lower.contains("toolchain")
379 || lower.contains("winget")
380 || lower.contains("choco")
381 || lower.contains("scoop")
382 || lower.contains("python")
383 || lower.contains("node")
384 {
385 FixPlanKind::EnvPath
386 } else {
387 FixPlanKind::Generic
388 }
389}
390
391fn inspect_env_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
392 let path_stats = analyze_path_env();
393 let toolchains = collect_toolchains();
394 let package_managers = collect_package_managers();
395 let findings = build_env_doctor_findings(&toolchains, &package_managers, &path_stats);
396 let found_tools = toolchains
397 .found
398 .iter()
399 .map(|(label, _)| label.as_str())
400 .collect::<HashSet<_>>();
401 let found_managers = package_managers
402 .found
403 .iter()
404 .map(|(label, _)| label.as_str())
405 .collect::<HashSet<_>>();
406
407 let mut out = String::from("Host inspection: fix_plan\n\n");
408 out.push_str(&format!("- Requested issue: {}\n", issue));
409 out.push_str("- Fix-plan type: environment/path\n");
410 out.push_str(&format!(
411 "- PATH health: {} duplicates, {} missing entries\n",
412 path_stats.duplicate_entries.len(),
413 path_stats.missing_entries.len()
414 ));
415 out.push_str(&format!("- Toolchains found: {}\n", toolchains.found.len()));
416 out.push_str(&format!(
417 "- Package managers found: {}\n",
418 package_managers.found.len()
419 ));
420
421 out.push_str("\nLikely causes:\n");
422 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
423 out.push_str(
424 "- 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",
425 );
426 }
427 if path_stats.duplicate_entries.is_empty()
428 && path_stats.missing_entries.is_empty()
429 && !findings.is_empty()
430 {
431 for finding in findings.iter().take(max_entries.max(4)) {
432 out.push_str(&format!("- {}\n", finding));
433 }
434 } else {
435 if !path_stats.duplicate_entries.is_empty() {
436 out.push_str("- Duplicate PATH rows create clutter and can hide which install path is actually winning.\n");
437 }
438 if !path_stats.missing_entries.is_empty() {
439 out.push_str("- Stale PATH rows point at directories that no longer exist, which makes environment drift harder to reason about.\n");
440 }
441 }
442 if found_tools.contains("node")
443 && !found_managers.contains("npm")
444 && !found_managers.contains("pnpm")
445 {
446 out.push_str("- Node is present without a detected package manager. That usually means a partial install or PATH drift.\n");
447 }
448 if found_tools.contains("python")
449 && !found_managers.contains("pip")
450 && !found_managers.contains("uv")
451 && !found_managers.contains("pipx")
452 {
453 out.push_str("- Python is present without a detected package manager. That usually means the launcher works but Scripts/bin is not discoverable.\n");
454 }
455
456 out.push_str("\nFix plan:\n");
457 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");
458 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
459 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");
460 } else if !found_tools.contains("rustc") && !found_managers.contains("cargo") {
461 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");
462 }
463 if !path_stats.duplicate_entries.is_empty() || !path_stats.missing_entries.is_empty() {
464 out.push_str("- Clean duplicate or dead PATH rows in Environment Variables so the winning toolchain path is obvious and stable.\n");
465 }
466 if found_tools.contains("node")
467 && !found_managers.contains("npm")
468 && !found_managers.contains("pnpm")
469 {
470 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");
471 }
472 if found_tools.contains("python")
473 && !found_managers.contains("pip")
474 && !found_managers.contains("uv")
475 && !found_managers.contains("pipx")
476 {
477 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");
478 }
479
480 if !path_stats.duplicate_entries.is_empty() {
481 out.push_str("\nExample duplicate PATH rows:\n");
482 for entry in path_stats.duplicate_entries.iter().take(max_entries.min(5)) {
483 out.push_str(&format!("- {}\n", entry));
484 }
485 }
486 if !path_stats.missing_entries.is_empty() {
487 out.push_str("\nExample missing PATH rows:\n");
488 for entry in path_stats.missing_entries.iter().take(max_entries.min(5)) {
489 out.push_str(&format!("- {}\n", entry));
490 }
491 }
492
493 out.push_str(
494 "\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.",
495 );
496 Ok(out.trim_end().to_string())
497}
498
499fn inspect_port_fix_plan(
500 issue: &str,
501 port_filter: Option<u16>,
502 max_entries: usize,
503) -> Result<String, String> {
504 let requested_port = port_filter.or_else(|| first_port_in_text(issue));
505 let listeners = collect_listening_ports().unwrap_or_default();
506 let mut matching = listeners;
507 if let Some(port) = requested_port {
508 matching.retain(|entry| entry.port == port);
509 }
510 let processes = collect_processes().unwrap_or_default();
511
512 let mut out = String::from("Host inspection: fix_plan\n\n");
513 out.push_str(&format!("- Requested issue: {}\n", issue));
514 out.push_str("- Fix-plan type: port_conflict\n");
515 if let Some(port) = requested_port {
516 out.push_str(&format!("- Requested port: {}\n", port));
517 } else {
518 out.push_str("- Requested port: not parsed from the issue text\n");
519 }
520 out.push_str(&format!("- Matching listeners found: {}\n", matching.len()));
521
522 if !matching.is_empty() {
523 out.push_str("\nCurrent listeners:\n");
524 for entry in matching.iter().take(max_entries.min(5)) {
525 let process_name = entry
526 .pid
527 .as_deref()
528 .and_then(|pid| pid.parse::<u32>().ok())
529 .and_then(|pid| {
530 processes
531 .iter()
532 .find(|process| process.pid == pid)
533 .map(|process| process.name.as_str())
534 })
535 .unwrap_or("unknown");
536 let pid = entry.pid.as_deref().unwrap_or("unknown");
537 out.push_str(&format!(
538 "- {} {} ({}) pid {} process {}\n",
539 entry.protocol, entry.local, entry.state, pid, process_name
540 ));
541 }
542 }
543
544 out.push_str("\nFix plan:\n");
545 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");
546 if !matching.is_empty() {
547 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");
548 } else {
549 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");
550 }
551 out.push_str("- If the port is intentionally occupied, move your app to another port instead of fighting the existing process.\n");
552 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");
553 out.push_str(
554 "\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.",
555 );
556 Ok(out.trim_end().to_string())
557}
558
559async fn inspect_lm_studio_fix_plan(issue: &str, max_entries: usize) -> Result<String, String> {
560 let config = crate::agent::config::load_config();
561 let configured_api = config
562 .api_url
563 .unwrap_or_else(|| "http://localhost:1234/v1".to_string());
564 let models_url = format!("{}/models", configured_api.trim_end_matches('/'));
565 let reachability = probe_http_endpoint(&models_url).await;
566 let embed_model = detect_loaded_embed_model(&configured_api).await;
567
568 let mut out = String::from("Host inspection: fix_plan\n\n");
569 out.push_str(&format!("- Requested issue: {}\n", issue));
570 out.push_str("- Fix-plan type: lm_studio\n");
571 out.push_str(&format!("- Configured API URL: {}\n", configured_api));
572 out.push_str(&format!("- Probe URL: {}\n", models_url));
573 match &reachability {
574 EndpointProbe::Reachable(status) => {
575 out.push_str(&format!("- Endpoint reachable: yes (HTTP {})\n", status))
576 }
577 EndpointProbe::Unreachable(detail) => {
578 out.push_str(&format!("- Endpoint reachable: no ({})\n", detail))
579 }
580 }
581 out.push_str(&format!(
582 "- Embedding model loaded: {}\n",
583 embed_model.as_deref().unwrap_or("none detected")
584 ));
585
586 out.push_str("\nFix plan:\n");
587 match reachability {
588 EndpointProbe::Reachable(_) => {
589 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");
590 }
591 EndpointProbe::Unreachable(_) => {
592 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");
593 }
594 }
595 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");
596 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");
597 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");
598 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");
599 if let Some(model) = embed_model {
600 out.push_str(&format!(
601 "- Current embedding model already visible: {}. That means the embeddings lane is configured, so focus on the chat model or endpoint next.\n",
602 model
603 ));
604 }
605 if max_entries > 0 {
606 out.push_str(
607 "\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.",
608 );
609 }
610 Ok(out.trim_end().to_string())
611}
612
613fn inspect_generic_fix_plan(issue: &str) -> Result<String, String> {
614 let mut out = String::from("Host inspection: fix_plan\n\n");
615 out.push_str(&format!("- Requested issue: {}\n", issue));
616 out.push_str("- Fix-plan type: generic\n");
617 out.push_str(
618 "\nGuidance:\n- Use `fix_plan` for one of the current structured remediation lanes: PATH/toolchain drift, port conflicts, or LM Studio connectivity.\n- If your issue is outside those lanes, run the closest `inspect_host` topic first to ground the diagnosis before proposing changes.",
619 );
620 Ok(out.trim_end().to_string())
621}
622
623#[derive(Debug)]
624enum EndpointProbe {
625 Reachable(u16),
626 Unreachable(String),
627}
628
629async fn probe_http_endpoint(url: &str) -> EndpointProbe {
630 let client = match reqwest::Client::builder()
631 .timeout(std::time::Duration::from_secs(3))
632 .build()
633 {
634 Ok(client) => client,
635 Err(err) => return EndpointProbe::Unreachable(err.to_string()),
636 };
637
638 match client.get(url).send().await {
639 Ok(resp) => EndpointProbe::Reachable(resp.status().as_u16()),
640 Err(err) => EndpointProbe::Unreachable(err.to_string()),
641 }
642}
643
644async fn detect_loaded_embed_model(configured_api: &str) -> Option<String> {
645 let base = configured_api.trim_end_matches("/v1").trim_end_matches('/');
646 let url = format!("{}/api/v0/models", base);
647 let client = reqwest::Client::builder()
648 .timeout(std::time::Duration::from_secs(3))
649 .build()
650 .ok()?;
651
652 #[derive(serde::Deserialize)]
653 struct ModelList {
654 data: Vec<ModelEntry>,
655 }
656 #[derive(serde::Deserialize)]
657 struct ModelEntry {
658 id: String,
659 #[serde(rename = "type", default)]
660 model_type: String,
661 #[serde(default)]
662 state: String,
663 }
664
665 let response = client.get(url).send().await.ok()?;
666 let models = response.json::<ModelList>().await.ok()?;
667 models
668 .data
669 .into_iter()
670 .find(|model| model.model_type == "embeddings" && model.state == "loaded")
671 .map(|model| model.id)
672}
673
674fn first_port_in_text(text: &str) -> Option<u16> {
675 text.split(|c: char| !c.is_ascii_digit())
676 .find(|fragment| !fragment.is_empty())
677 .and_then(|fragment| fragment.parse::<u16>().ok())
678}
679
680fn inspect_processes(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
681 let mut processes = collect_processes()?;
682 if let Some(filter) = name_filter.as_deref() {
683 let lowered = filter.to_ascii_lowercase();
684 processes.retain(|entry| entry.name.to_ascii_lowercase().contains(&lowered));
685 }
686 processes.sort_by(|a, b| {
687 b.memory_bytes
688 .cmp(&a.memory_bytes)
689 .then_with(|| a.name.cmp(&b.name))
690 .then_with(|| a.pid.cmp(&b.pid))
691 });
692
693 let total_memory: u64 = processes.iter().map(|entry| entry.memory_bytes).sum();
694
695 let mut out = String::from("Host inspection: processes\n\n");
696 if let Some(filter) = name_filter.as_deref() {
697 out.push_str(&format!("- Filter name: {}\n", filter));
698 }
699 out.push_str(&format!("- Processes found: {}\n", processes.len()));
700 out.push_str(&format!(
701 "- Total reported working set: {}\n",
702 human_bytes(total_memory)
703 ));
704
705 if processes.is_empty() {
706 out.push_str("\nNo running processes matched.");
707 return Ok(out);
708 }
709
710 out.push_str("\nTop processes by memory:\n");
711 for entry in processes.iter().take(max_entries) {
712 out.push_str(&format!(
713 "- {} (pid {}) - {}{}\n",
714 entry.name,
715 entry.pid,
716 human_bytes(entry.memory_bytes),
717 entry
718 .detail
719 .as_deref()
720 .map(|detail| format!(" [{}]", detail))
721 .unwrap_or_default()
722 ));
723 }
724 if processes.len() > max_entries {
725 out.push_str(&format!(
726 "- ... {} more processes omitted\n",
727 processes.len() - max_entries
728 ));
729 }
730
731 Ok(out.trim_end().to_string())
732}
733
734fn inspect_network(max_entries: usize) -> Result<String, String> {
735 let adapters = collect_network_adapters()?;
736 let active_count = adapters
737 .iter()
738 .filter(|adapter| adapter.is_active())
739 .count();
740 let exposure = listener_exposure_summary(collect_listening_ports().ok().unwrap_or_default());
741
742 let mut out = String::from("Host inspection: network\n\n");
743 out.push_str(&format!("- Adapters found: {}\n", adapters.len()));
744 out.push_str(&format!("- Active adapters: {}\n", active_count));
745 out.push_str(&format!(
746 "- Listener exposure: {} loopback-only, {} wildcard/public, {} specific-bind\n",
747 exposure.loopback_only, exposure.wildcard_public, exposure.specific_bind
748 ));
749
750 if adapters.is_empty() {
751 out.push_str("\nNo adapter details were detected.");
752 return Ok(out);
753 }
754
755 out.push_str("\nAdapter summary:\n");
756 for adapter in adapters.iter().take(max_entries) {
757 let status = if adapter.is_active() {
758 "active"
759 } else if adapter.disconnected {
760 "disconnected"
761 } else {
762 "idle"
763 };
764 let mut details = vec![status.to_string()];
765 if !adapter.ipv4.is_empty() {
766 details.push(format!("ipv4 {}", adapter.ipv4.join(", ")));
767 }
768 if !adapter.ipv6.is_empty() {
769 details.push(format!("ipv6 {}", adapter.ipv6.join(", ")));
770 }
771 if !adapter.gateways.is_empty() {
772 details.push(format!("gateway {}", adapter.gateways.join(", ")));
773 }
774 if !adapter.dns_servers.is_empty() {
775 details.push(format!("dns {}", adapter.dns_servers.join(", ")));
776 }
777 out.push_str(&format!("- {} - {}\n", adapter.name, details.join(" | ")));
778 }
779 if adapters.len() > max_entries {
780 out.push_str(&format!(
781 "- ... {} more adapters omitted\n",
782 adapters.len() - max_entries
783 ));
784 }
785
786 Ok(out.trim_end().to_string())
787}
788
789fn inspect_services(name_filter: Option<String>, max_entries: usize) -> Result<String, String> {
790 let mut services = collect_services()?;
791 if let Some(filter) = name_filter.as_deref() {
792 let lowered = filter.to_ascii_lowercase();
793 services.retain(|entry| {
794 entry.name.to_ascii_lowercase().contains(&lowered)
795 || entry
796 .display_name
797 .as_deref()
798 .unwrap_or("")
799 .to_ascii_lowercase()
800 .contains(&lowered)
801 });
802 }
803
804 services.sort_by(|a, b| {
805 service_status_rank(&a.status)
806 .cmp(&service_status_rank(&b.status))
807 .then_with(|| a.name.cmp(&b.name))
808 });
809
810 let running = services
811 .iter()
812 .filter(|entry| {
813 entry.status.eq_ignore_ascii_case("running")
814 || entry.status.eq_ignore_ascii_case("active")
815 })
816 .count();
817 let failed = services
818 .iter()
819 .filter(|entry| {
820 entry.status.eq_ignore_ascii_case("failed")
821 || entry.status.eq_ignore_ascii_case("error")
822 || entry.status.eq_ignore_ascii_case("stopped")
823 })
824 .count();
825
826 let mut out = String::from("Host inspection: services\n\n");
827 if let Some(filter) = name_filter.as_deref() {
828 out.push_str(&format!("- Filter name: {}\n", filter));
829 }
830 out.push_str(&format!("- Services found: {}\n", services.len()));
831 out.push_str(&format!("- Running/active: {}\n", running));
832 out.push_str(&format!("- Failed/stopped: {}\n", failed));
833
834 if services.is_empty() {
835 out.push_str("\nNo services matched.");
836 return Ok(out);
837 }
838
839 out.push_str("\nService summary:\n");
840 for entry in services.iter().take(max_entries) {
841 let startup = entry
842 .startup
843 .as_deref()
844 .map(|value| format!(" | startup {}", value))
845 .unwrap_or_default();
846 let display = entry
847 .display_name
848 .as_deref()
849 .filter(|value| *value != &entry.name)
850 .map(|value| format!(" [{}]", value))
851 .unwrap_or_default();
852 out.push_str(&format!(
853 "- {}{} - {}{}\n",
854 entry.name, display, entry.status, startup
855 ));
856 }
857 if services.len() > max_entries {
858 out.push_str(&format!(
859 "- ... {} more services omitted\n",
860 services.len() - max_entries
861 ));
862 }
863
864 Ok(out.trim_end().to_string())
865}
866
867async fn inspect_disk(path: PathBuf, max_entries: usize) -> Result<String, String> {
868 inspect_directory("Disk", path, max_entries).await
869}
870
871fn inspect_ports(port_filter: Option<u16>, max_entries: usize) -> Result<String, String> {
872 let mut listeners = collect_listening_ports()?;
873 if let Some(port) = port_filter {
874 listeners.retain(|entry| entry.port == port);
875 }
876 listeners.sort_by(|a, b| a.port.cmp(&b.port).then_with(|| a.local.cmp(&b.local)));
877
878 let mut out = String::from("Host inspection: ports\n\n");
879 if let Some(port) = port_filter {
880 out.push_str(&format!("- Filter port: {}\n", port));
881 }
882 out.push_str(&format!(
883 "- Listening endpoints found: {}\n",
884 listeners.len()
885 ));
886
887 if listeners.is_empty() {
888 out.push_str("\nNo listening endpoints matched.");
889 return Ok(out);
890 }
891
892 out.push_str("\nListening endpoints:\n");
893 for entry in listeners.iter().take(max_entries) {
894 let pid = entry
895 .pid
896 .as_deref()
897 .map(|pid| format!(" pid {}", pid))
898 .unwrap_or_default();
899 out.push_str(&format!(
900 "- {} {} ({}){}\n",
901 entry.protocol, entry.local, entry.state, pid
902 ));
903 }
904 if listeners.len() > max_entries {
905 out.push_str(&format!(
906 "- ... {} more listening endpoints omitted\n",
907 listeners.len() - max_entries
908 ));
909 }
910
911 Ok(out.trim_end().to_string())
912}
913
914fn inspect_repo_doctor(path: PathBuf, max_entries: usize) -> Result<String, String> {
915 if !path.exists() {
916 return Err(format!("Path does not exist: {}", path.display()));
917 }
918 if !path.is_dir() {
919 return Err(format!("Path is not a directory: {}", path.display()));
920 }
921
922 let markers = collect_project_markers(&path);
923 let hematite_state = collect_hematite_state(&path);
924 let git_state = inspect_git_state(&path);
925 let release_state = inspect_release_artifacts(&path);
926
927 let mut out = String::from("Host inspection: repo_doctor\n\n");
928 out.push_str(&format!("- Path: {}\n", path.display()));
929 out.push_str(&format!(
930 "- Workspace mode: {}\n",
931 workspace_mode_for_path(&path)
932 ));
933
934 if markers.is_empty() {
935 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");
936 } else {
937 out.push_str("- Project markers:\n");
938 for marker in markers.iter().take(max_entries) {
939 out.push_str(&format!(" - {}\n", marker));
940 }
941 }
942
943 match git_state {
944 Some(git) => {
945 out.push_str(&format!("- Git root: {}\n", git.root.display()));
946 out.push_str(&format!("- Git branch: {}\n", git.branch));
947 out.push_str(&format!("- Git status: {}\n", git.status_label()));
948 }
949 None => out.push_str("- Git: not inside a detected work tree\n"),
950 }
951
952 out.push_str(&format!(
953 "- Hematite docs/imports/reports: {}/{}/{}\n",
954 hematite_state.docs_count, hematite_state.import_count, hematite_state.report_count
955 ));
956 if hematite_state.workspace_profile {
957 out.push_str("- Workspace profile: present\n");
958 } else {
959 out.push_str("- Workspace profile: absent\n");
960 }
961
962 if let Some(release) = release_state {
963 out.push_str(&format!("- Cargo version: {}\n", release.version));
964 out.push_str(&format!(
965 "- Windows artifacts for current version: {}/{}/{}\n",
966 bool_label(release.portable_dir),
967 bool_label(release.portable_zip),
968 bool_label(release.setup_exe)
969 ));
970 }
971
972 Ok(out.trim_end().to_string())
973}
974
975async fn inspect_known_directory(
976 label: &str,
977 path: Option<PathBuf>,
978 max_entries: usize,
979) -> Result<String, String> {
980 let path = path.ok_or_else(|| format!("{} location is unavailable on this host.", label))?;
981 inspect_directory(label, path, max_entries).await
982}
983
984async fn inspect_directory(
985 label: &str,
986 path: PathBuf,
987 max_entries: usize,
988) -> Result<String, String> {
989 let label = label.to_string();
990 tokio::task::spawn_blocking(move || inspect_directory_sync(&label, &path, max_entries))
991 .await
992 .map_err(|e| format!("inspect_host task failed: {e}"))?
993}
994
995fn inspect_directory_sync(label: &str, path: &Path, max_entries: usize) -> Result<String, String> {
996 if !path.exists() {
997 return Err(format!("Path does not exist: {}", path.display()));
998 }
999 if !path.is_dir() {
1000 return Err(format!("Path is not a directory: {}", path.display()));
1001 }
1002
1003 let mut top_level_entries = Vec::new();
1004 for entry in fs::read_dir(path)
1005 .map_err(|e| format!("Failed to read directory {}: {e}", path.display()))?
1006 {
1007 match entry {
1008 Ok(entry) => top_level_entries.push(entry),
1009 Err(_) => continue,
1010 }
1011 }
1012 top_level_entries.sort_by_key(|entry| entry.file_name());
1013
1014 let top_level_count = top_level_entries.len();
1015 let mut sample_names = Vec::new();
1016 let mut largest_entries = Vec::new();
1017 let mut aggregate = PathAggregate::default();
1018 let mut budget = DIRECTORY_SCAN_NODE_BUDGET;
1019
1020 for entry in top_level_entries {
1021 let name = entry.file_name().to_string_lossy().to_string();
1022 if sample_names.len() < max_entries {
1023 sample_names.push(name.clone());
1024 }
1025 let kind = match entry.file_type() {
1026 Ok(ft) if ft.is_dir() => "dir",
1027 Ok(ft) if ft.is_symlink() => "symlink",
1028 _ => "file",
1029 };
1030 let stats = measure_path(&entry.path(), &mut budget);
1031 aggregate.merge(&stats);
1032 largest_entries.push(LargestEntry {
1033 name,
1034 kind,
1035 bytes: stats.total_bytes,
1036 });
1037 }
1038
1039 largest_entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(&b.name)));
1040
1041 let mut out = format!("Directory inspection: {}\n\n", label);
1042 out.push_str(&format!("- Path: {}\n", path.display()));
1043 out.push_str(&format!("- Top-level items: {}\n", top_level_count));
1044 out.push_str(&format!("- Recursive files: {}\n", aggregate.file_count));
1045 out.push_str(&format!(
1046 "- Recursive directories: {}\n",
1047 aggregate.dir_count
1048 ));
1049 out.push_str(&format!(
1050 "- Total size: {}{}\n",
1051 human_bytes(aggregate.total_bytes),
1052 if aggregate.partial {
1053 " (partial scan)"
1054 } else {
1055 ""
1056 }
1057 ));
1058 if aggregate.skipped_entries > 0 {
1059 out.push_str(&format!(
1060 "- Skipped entries: {} (permissions, symlinks, or scan budget)\n",
1061 aggregate.skipped_entries
1062 ));
1063 }
1064
1065 if !largest_entries.is_empty() {
1066 out.push_str("\nLargest top-level entries:\n");
1067 for entry in largest_entries.iter().take(max_entries) {
1068 out.push_str(&format!(
1069 "- {} [{}] - {}\n",
1070 entry.name,
1071 entry.kind,
1072 human_bytes(entry.bytes)
1073 ));
1074 }
1075 }
1076
1077 if !sample_names.is_empty() {
1078 out.push_str("\nSample names:\n");
1079 for name in sample_names {
1080 out.push_str(&format!("- {}\n", name));
1081 }
1082 }
1083
1084 Ok(out.trim_end().to_string())
1085}
1086
1087fn resolve_path(raw: &str) -> Result<PathBuf, String> {
1088 let trimmed = raw.trim();
1089 if trimmed.is_empty() {
1090 return Err("Path must not be empty.".to_string());
1091 }
1092
1093 if let Some(rest) = trimmed
1094 .strip_prefix("~/")
1095 .or_else(|| trimmed.strip_prefix("~\\"))
1096 {
1097 let home = home::home_dir().ok_or_else(|| "Home directory is unavailable.".to_string())?;
1098 return Ok(home.join(rest));
1099 }
1100
1101 let path = PathBuf::from(trimmed);
1102 if path.is_absolute() {
1103 Ok(path)
1104 } else {
1105 let cwd =
1106 std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
1107 Ok(cwd.join(path))
1108 }
1109}
1110
1111fn workspace_mode_label(workspace_root: &Path) -> &'static str {
1112 workspace_mode_for_path(workspace_root)
1113}
1114
1115fn workspace_mode_for_path(path: &Path) -> &'static str {
1116 if is_project_marker_path(path) {
1117 "project"
1118 } else if path.join(".hematite").join("docs").exists()
1119 || path.join(".hematite").join("imports").exists()
1120 || path.join(".hematite").join("reports").exists()
1121 {
1122 "docs-only"
1123 } else {
1124 "general directory"
1125 }
1126}
1127
1128fn is_project_marker_path(path: &Path) -> bool {
1129 [
1130 "Cargo.toml",
1131 "package.json",
1132 "pyproject.toml",
1133 "go.mod",
1134 "composer.json",
1135 "requirements.txt",
1136 "Makefile",
1137 "justfile",
1138 ]
1139 .iter()
1140 .any(|name| path.join(name).exists())
1141 || path.join(".git").exists()
1142}
1143
1144fn preferred_shell_label() -> &'static str {
1145 #[cfg(target_os = "windows")]
1146 {
1147 "PowerShell"
1148 }
1149 #[cfg(not(target_os = "windows"))]
1150 {
1151 "sh"
1152 }
1153}
1154
1155fn desktop_dir() -> Option<PathBuf> {
1156 home::home_dir().map(|home| home.join("Desktop"))
1157}
1158
1159fn downloads_dir() -> Option<PathBuf> {
1160 home::home_dir().map(|home| home.join("Downloads"))
1161}
1162
1163fn count_top_level_items(path: &Path) -> Result<usize, String> {
1164 let mut count = 0usize;
1165 for entry in
1166 fs::read_dir(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?
1167 {
1168 if entry.is_ok() {
1169 count += 1;
1170 }
1171 }
1172 Ok(count)
1173}
1174
1175#[derive(Default)]
1176struct PathAggregate {
1177 total_bytes: u64,
1178 file_count: u64,
1179 dir_count: u64,
1180 skipped_entries: u64,
1181 partial: bool,
1182}
1183
1184impl PathAggregate {
1185 fn merge(&mut self, other: &PathAggregate) {
1186 self.total_bytes += other.total_bytes;
1187 self.file_count += other.file_count;
1188 self.dir_count += other.dir_count;
1189 self.skipped_entries += other.skipped_entries;
1190 self.partial |= other.partial;
1191 }
1192}
1193
1194struct LargestEntry {
1195 name: String,
1196 kind: &'static str,
1197 bytes: u64,
1198}
1199
1200fn measure_path(path: &Path, budget: &mut usize) -> PathAggregate {
1201 if *budget == 0 {
1202 return PathAggregate {
1203 partial: true,
1204 skipped_entries: 1,
1205 ..PathAggregate::default()
1206 };
1207 }
1208 *budget -= 1;
1209
1210 let metadata = match fs::symlink_metadata(path) {
1211 Ok(metadata) => metadata,
1212 Err(_) => {
1213 return PathAggregate {
1214 skipped_entries: 1,
1215 ..PathAggregate::default()
1216 }
1217 }
1218 };
1219
1220 let file_type = metadata.file_type();
1221 if file_type.is_symlink() {
1222 return PathAggregate {
1223 skipped_entries: 1,
1224 ..PathAggregate::default()
1225 };
1226 }
1227
1228 if metadata.is_file() {
1229 return PathAggregate {
1230 total_bytes: metadata.len(),
1231 file_count: 1,
1232 ..PathAggregate::default()
1233 };
1234 }
1235
1236 if !metadata.is_dir() {
1237 return PathAggregate::default();
1238 }
1239
1240 let mut aggregate = PathAggregate {
1241 dir_count: 1,
1242 ..PathAggregate::default()
1243 };
1244
1245 let read_dir = match fs::read_dir(path) {
1246 Ok(read_dir) => read_dir,
1247 Err(_) => {
1248 aggregate.skipped_entries += 1;
1249 return aggregate;
1250 }
1251 };
1252
1253 for child in read_dir {
1254 match child {
1255 Ok(child) => {
1256 let child_stats = measure_path(&child.path(), budget);
1257 aggregate.merge(&child_stats);
1258 }
1259 Err(_) => aggregate.skipped_entries += 1,
1260 }
1261 }
1262
1263 aggregate
1264}
1265
1266struct PathAnalysis {
1267 total_entries: usize,
1268 unique_entries: usize,
1269 entries: Vec<String>,
1270 duplicate_entries: Vec<String>,
1271 missing_entries: Vec<String>,
1272}
1273
1274fn analyze_path_env() -> PathAnalysis {
1275 let mut entries = Vec::new();
1276 let mut duplicate_entries = Vec::new();
1277 let mut missing_entries = Vec::new();
1278 let mut seen = HashSet::new();
1279
1280 let raw_path = std::env::var_os("PATH").unwrap_or_default();
1281 for path in std::env::split_paths(&raw_path) {
1282 let display = path.display().to_string();
1283 if display.trim().is_empty() {
1284 continue;
1285 }
1286
1287 let normalized = normalize_path_entry(&display);
1288 if !seen.insert(normalized) {
1289 duplicate_entries.push(display.clone());
1290 }
1291 if !path.exists() {
1292 missing_entries.push(display.clone());
1293 }
1294 entries.push(display);
1295 }
1296
1297 let total_entries = entries.len();
1298 let unique_entries = seen.len();
1299
1300 PathAnalysis {
1301 total_entries,
1302 unique_entries,
1303 entries,
1304 duplicate_entries,
1305 missing_entries,
1306 }
1307}
1308
1309fn normalize_path_entry(value: &str) -> String {
1310 #[cfg(target_os = "windows")]
1311 {
1312 value
1313 .replace('/', "\\")
1314 .trim_end_matches(['\\', '/'])
1315 .to_ascii_lowercase()
1316 }
1317 #[cfg(not(target_os = "windows"))]
1318 {
1319 value.trim_end_matches('/').to_string()
1320 }
1321}
1322
1323struct ToolchainReport {
1324 found: Vec<(String, String)>,
1325 missing: Vec<String>,
1326}
1327
1328struct PackageManagerReport {
1329 found: Vec<(String, String)>,
1330}
1331
1332#[derive(Debug, Clone)]
1333struct ProcessEntry {
1334 name: String,
1335 pid: u32,
1336 memory_bytes: u64,
1337 detail: Option<String>,
1338}
1339
1340#[derive(Debug, Clone)]
1341struct ServiceEntry {
1342 name: String,
1343 status: String,
1344 startup: Option<String>,
1345 display_name: Option<String>,
1346}
1347
1348#[derive(Debug, Clone, Default)]
1349struct NetworkAdapter {
1350 name: String,
1351 ipv4: Vec<String>,
1352 ipv6: Vec<String>,
1353 gateways: Vec<String>,
1354 dns_servers: Vec<String>,
1355 disconnected: bool,
1356}
1357
1358impl NetworkAdapter {
1359 fn is_active(&self) -> bool {
1360 !self.disconnected
1361 && (!self.ipv4.is_empty() || !self.ipv6.is_empty() || !self.gateways.is_empty())
1362 }
1363}
1364
1365#[derive(Debug, Clone, Copy, Default)]
1366struct ListenerExposureSummary {
1367 loopback_only: usize,
1368 wildcard_public: usize,
1369 specific_bind: usize,
1370}
1371
1372#[derive(Debug, Clone)]
1373struct ListeningPort {
1374 protocol: String,
1375 local: String,
1376 port: u16,
1377 state: String,
1378 pid: Option<String>,
1379}
1380
1381fn collect_listening_ports() -> Result<Vec<ListeningPort>, String> {
1382 #[cfg(target_os = "windows")]
1383 {
1384 collect_windows_listening_ports()
1385 }
1386 #[cfg(not(target_os = "windows"))]
1387 {
1388 collect_unix_listening_ports()
1389 }
1390}
1391
1392fn collect_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
1393 #[cfg(target_os = "windows")]
1394 {
1395 collect_windows_network_adapters()
1396 }
1397 #[cfg(not(target_os = "windows"))]
1398 {
1399 collect_unix_network_adapters()
1400 }
1401}
1402
1403fn collect_services() -> Result<Vec<ServiceEntry>, String> {
1404 #[cfg(target_os = "windows")]
1405 {
1406 collect_windows_services()
1407 }
1408 #[cfg(not(target_os = "windows"))]
1409 {
1410 collect_unix_services()
1411 }
1412}
1413
1414#[cfg(target_os = "windows")]
1415fn collect_windows_listening_ports() -> Result<Vec<ListeningPort>, String> {
1416 let output = Command::new("netstat")
1417 .args(["-ano", "-p", "tcp"])
1418 .output()
1419 .map_err(|e| format!("Failed to run netstat: {e}"))?;
1420 if !output.status.success() {
1421 return Err("netstat returned a non-success status.".to_string());
1422 }
1423
1424 let text = String::from_utf8_lossy(&output.stdout);
1425 let mut listeners = Vec::new();
1426 for line in text.lines() {
1427 let trimmed = line.trim();
1428 if !trimmed.starts_with("TCP") {
1429 continue;
1430 }
1431 let cols: Vec<&str> = trimmed.split_whitespace().collect();
1432 if cols.len() < 5 || cols[3] != "LISTENING" {
1433 continue;
1434 }
1435 let Some(port) = extract_port_from_socket(cols[1]) else {
1436 continue;
1437 };
1438 listeners.push(ListeningPort {
1439 protocol: cols[0].to_string(),
1440 local: cols[1].to_string(),
1441 port,
1442 state: cols[3].to_string(),
1443 pid: Some(cols[4].to_string()),
1444 });
1445 }
1446
1447 Ok(listeners)
1448}
1449
1450#[cfg(not(target_os = "windows"))]
1451fn collect_unix_listening_ports() -> Result<Vec<ListeningPort>, String> {
1452 let output = Command::new("ss")
1453 .args(["-ltn"])
1454 .output()
1455 .map_err(|e| format!("Failed to run ss: {e}"))?;
1456 if !output.status.success() {
1457 return Err("ss returned a non-success status.".to_string());
1458 }
1459
1460 let text = String::from_utf8_lossy(&output.stdout);
1461 let mut listeners = Vec::new();
1462 for line in text.lines().skip(1) {
1463 let cols: Vec<&str> = line.split_whitespace().collect();
1464 if cols.len() < 4 {
1465 continue;
1466 }
1467 let Some(port) = extract_port_from_socket(cols[3]) else {
1468 continue;
1469 };
1470 listeners.push(ListeningPort {
1471 protocol: "tcp".to_string(),
1472 local: cols[3].to_string(),
1473 port,
1474 state: cols[0].to_string(),
1475 pid: None,
1476 });
1477 }
1478
1479 Ok(listeners)
1480}
1481
1482fn collect_processes() -> Result<Vec<ProcessEntry>, String> {
1483 #[cfg(target_os = "windows")]
1484 {
1485 collect_windows_processes()
1486 }
1487 #[cfg(not(target_os = "windows"))]
1488 {
1489 collect_unix_processes()
1490 }
1491}
1492
1493#[cfg(target_os = "windows")]
1494fn collect_windows_services() -> Result<Vec<ServiceEntry>, String> {
1495 let command = "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,DisplayName | ConvertTo-Json -Compress";
1496 let output = Command::new("powershell")
1497 .args(["-NoProfile", "-Command", command])
1498 .output()
1499 .map_err(|e| format!("Failed to run PowerShell service inspection: {e}"))?;
1500 if !output.status.success() {
1501 return Err("PowerShell service inspection returned a non-success status.".to_string());
1502 }
1503
1504 parse_windows_services_json(&String::from_utf8_lossy(&output.stdout))
1505}
1506
1507#[cfg(not(target_os = "windows"))]
1508fn collect_unix_services() -> Result<Vec<ServiceEntry>, String> {
1509 let status_output = Command::new("systemctl")
1510 .args([
1511 "list-units",
1512 "--type=service",
1513 "--all",
1514 "--no-pager",
1515 "--no-legend",
1516 "--plain",
1517 ])
1518 .output()
1519 .map_err(|e| format!("Failed to run systemctl list-units: {e}"))?;
1520 if !status_output.status.success() {
1521 return Err("systemctl list-units returned a non-success status.".to_string());
1522 }
1523
1524 let startup_output = Command::new("systemctl")
1525 .args([
1526 "list-unit-files",
1527 "--type=service",
1528 "--no-legend",
1529 "--no-pager",
1530 "--plain",
1531 ])
1532 .output()
1533 .map_err(|e| format!("Failed to run systemctl list-unit-files: {e}"))?;
1534 if !startup_output.status.success() {
1535 return Err("systemctl list-unit-files returned a non-success status.".to_string());
1536 }
1537
1538 Ok(parse_unix_services(
1539 &String::from_utf8_lossy(&status_output.stdout),
1540 &String::from_utf8_lossy(&startup_output.stdout),
1541 ))
1542}
1543
1544#[cfg(target_os = "windows")]
1545fn collect_windows_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
1546 let output = Command::new("ipconfig")
1547 .args(["/all"])
1548 .output()
1549 .map_err(|e| format!("Failed to run ipconfig: {e}"))?;
1550 if !output.status.success() {
1551 return Err("ipconfig returned a non-success status.".to_string());
1552 }
1553
1554 Ok(parse_windows_ipconfig_all(&String::from_utf8_lossy(
1555 &output.stdout,
1556 )))
1557}
1558
1559#[cfg(not(target_os = "windows"))]
1560fn collect_unix_network_adapters() -> Result<Vec<NetworkAdapter>, String> {
1561 let addr_output = Command::new("ip")
1562 .args(["-o", "addr", "show", "up"])
1563 .output()
1564 .map_err(|e| format!("Failed to run ip addr: {e}"))?;
1565 if !addr_output.status.success() {
1566 return Err("ip addr returned a non-success status.".to_string());
1567 }
1568
1569 let route_output = Command::new("ip")
1570 .args(["route", "show", "default"])
1571 .output()
1572 .map_err(|e| format!("Failed to run ip route: {e}"))?;
1573 if !route_output.status.success() {
1574 return Err("ip route returned a non-success status.".to_string());
1575 }
1576
1577 let mut adapters = parse_unix_ip_addr(&String::from_utf8_lossy(&addr_output.stdout));
1578 apply_unix_default_routes(
1579 &mut adapters,
1580 &String::from_utf8_lossy(&route_output.stdout),
1581 );
1582 apply_unix_dns_servers(&mut adapters);
1583 Ok(adapters)
1584}
1585
1586#[cfg(target_os = "windows")]
1587fn collect_windows_processes() -> Result<Vec<ProcessEntry>, String> {
1588 let output = Command::new("tasklist")
1589 .args(["/FO", "CSV", "/NH"])
1590 .output()
1591 .map_err(|e| format!("Failed to run tasklist: {e}"))?;
1592 if !output.status.success() {
1593 return Err("tasklist returned a non-success status.".to_string());
1594 }
1595
1596 let text = String::from_utf8_lossy(&output.stdout);
1597 let mut processes = Vec::new();
1598 for line in text.lines() {
1599 let cols = parse_csv_row(line);
1600 if cols.len() < 5 {
1601 continue;
1602 }
1603 let Some(pid) = cols[1].trim().parse::<u32>().ok() else {
1604 continue;
1605 };
1606 processes.push(ProcessEntry {
1607 name: cols[0].trim().to_string(),
1608 pid,
1609 memory_bytes: parse_tasklist_memory_kib(&cols[4]).unwrap_or(0) * 1024,
1610 detail: Some(format!("session {}", cols[2].trim())),
1611 });
1612 }
1613
1614 Ok(processes)
1615}
1616
1617#[cfg(not(target_os = "windows"))]
1618fn collect_unix_processes() -> Result<Vec<ProcessEntry>, String> {
1619 let output = Command::new("ps")
1620 .args(["-eo", "pid=,rss=,comm="])
1621 .output()
1622 .map_err(|e| format!("Failed to run ps: {e}"))?;
1623 if !output.status.success() {
1624 return Err("ps returned a non-success status.".to_string());
1625 }
1626
1627 let text = String::from_utf8_lossy(&output.stdout);
1628 let mut processes = Vec::new();
1629 for line in text.lines() {
1630 let cols: Vec<&str> = line.split_whitespace().collect();
1631 if cols.len() < 3 {
1632 continue;
1633 }
1634 let (Some(pid), Some(rss_kib)) = (cols[0].parse::<u32>().ok(), cols[1].parse::<u64>().ok())
1635 else {
1636 continue;
1637 };
1638 processes.push(ProcessEntry {
1639 name: cols[2..].join(" "),
1640 pid,
1641 memory_bytes: rss_kib * 1024,
1642 detail: None,
1643 });
1644 }
1645
1646 Ok(processes)
1647}
1648
1649fn extract_port_from_socket(value: &str) -> Option<u16> {
1650 let cleaned = value.trim().trim_matches(['[', ']']);
1651 let port_str = cleaned.rsplit(':').next()?;
1652 port_str.parse::<u16>().ok()
1653}
1654
1655fn listener_exposure_summary(listeners: Vec<ListeningPort>) -> ListenerExposureSummary {
1656 let mut summary = ListenerExposureSummary::default();
1657 for entry in listeners {
1658 let local = entry.local.to_ascii_lowercase();
1659 if is_loopback_listener(&local) {
1660 summary.loopback_only += 1;
1661 } else if is_wildcard_listener(&local) {
1662 summary.wildcard_public += 1;
1663 } else {
1664 summary.specific_bind += 1;
1665 }
1666 }
1667 summary
1668}
1669
1670fn service_status_rank(status: &str) -> u8 {
1671 let lower = status.to_ascii_lowercase();
1672 if lower == "failed" || lower == "error" {
1673 0
1674 } else if lower == "running" || lower == "active" {
1675 1
1676 } else if lower == "starting" || lower == "activating" {
1677 2
1678 } else {
1679 3
1680 }
1681}
1682
1683fn is_loopback_listener(local: &str) -> bool {
1684 local.starts_with("127.")
1685 || local.starts_with("[::1]")
1686 || local.starts_with("::1")
1687 || local.starts_with("localhost:")
1688}
1689
1690fn is_wildcard_listener(local: &str) -> bool {
1691 local.starts_with("0.0.0.0:")
1692 || local.starts_with("[::]:")
1693 || local.starts_with(":::")
1694 || local == "*:*"
1695}
1696
1697struct GitState {
1698 root: PathBuf,
1699 branch: String,
1700 dirty_entries: usize,
1701}
1702
1703impl GitState {
1704 fn status_label(&self) -> String {
1705 if self.dirty_entries == 0 {
1706 "clean".to_string()
1707 } else {
1708 format!("dirty ({} changed path(s))", self.dirty_entries)
1709 }
1710 }
1711}
1712
1713fn inspect_git_state(path: &Path) -> Option<GitState> {
1714 let root = capture_first_line(
1715 "git",
1716 &["-C", path.to_str()?, "rev-parse", "--show-toplevel"],
1717 )?;
1718 let branch = capture_first_line("git", &["-C", path.to_str()?, "branch", "--show-current"])
1719 .unwrap_or_else(|| "detached".to_string());
1720 let output = Command::new("git")
1721 .args(["-C", path.to_str()?, "status", "--short"])
1722 .output()
1723 .ok()?;
1724 if !output.status.success() {
1725 return None;
1726 }
1727 let dirty_entries = String::from_utf8_lossy(&output.stdout).lines().count();
1728 Some(GitState {
1729 root: PathBuf::from(root),
1730 branch,
1731 dirty_entries,
1732 })
1733}
1734
1735struct HematiteState {
1736 docs_count: usize,
1737 import_count: usize,
1738 report_count: usize,
1739 workspace_profile: bool,
1740}
1741
1742fn collect_hematite_state(path: &Path) -> HematiteState {
1743 let root = path.join(".hematite");
1744 HematiteState {
1745 docs_count: count_entries_if_exists(&root.join("docs")),
1746 import_count: count_entries_if_exists(&root.join("imports")),
1747 report_count: count_entries_if_exists(&root.join("reports")),
1748 workspace_profile: root.join("workspace_profile.json").exists(),
1749 }
1750}
1751
1752fn count_entries_if_exists(path: &Path) -> usize {
1753 if !path.exists() || !path.is_dir() {
1754 return 0;
1755 }
1756 fs::read_dir(path)
1757 .ok()
1758 .map(|iter| iter.filter(|entry| entry.is_ok()).count())
1759 .unwrap_or(0)
1760}
1761
1762fn collect_project_markers(path: &Path) -> Vec<String> {
1763 [
1764 "Cargo.toml",
1765 "package.json",
1766 "pyproject.toml",
1767 "go.mod",
1768 "justfile",
1769 "Makefile",
1770 ".git",
1771 ]
1772 .iter()
1773 .filter_map(|name| path.join(name).exists().then(|| (*name).to_string()))
1774 .collect()
1775}
1776
1777struct ReleaseArtifactState {
1778 version: String,
1779 portable_dir: bool,
1780 portable_zip: bool,
1781 setup_exe: bool,
1782}
1783
1784fn inspect_release_artifacts(path: &Path) -> Option<ReleaseArtifactState> {
1785 let cargo_toml = path.join("Cargo.toml");
1786 if !cargo_toml.exists() {
1787 return None;
1788 }
1789 let cargo_text = fs::read_to_string(cargo_toml).ok()?;
1790 let version = [regex_line_capture(
1791 &cargo_text,
1792 r#"(?m)^version\s*=\s*"([^"]+)""#,
1793 )?]
1794 .concat();
1795 let dist_windows = path.join("dist").join("windows");
1796 let prefix = format!("Hematite-{}", version);
1797 Some(ReleaseArtifactState {
1798 version,
1799 portable_dir: dist_windows.join(format!("{}-portable", prefix)).exists(),
1800 portable_zip: dist_windows
1801 .join(format!("{}-portable.zip", prefix))
1802 .exists(),
1803 setup_exe: dist_windows.join(format!("{}-Setup.exe", prefix)).exists(),
1804 })
1805}
1806
1807fn regex_line_capture(text: &str, pattern: &str) -> Option<String> {
1808 let regex = regex::Regex::new(pattern).ok()?;
1809 let captures = regex.captures(text)?;
1810 captures.get(1).map(|m| m.as_str().to_string())
1811}
1812
1813fn bool_label(value: bool) -> &'static str {
1814 if value {
1815 "yes"
1816 } else {
1817 "no"
1818 }
1819}
1820
1821fn collect_toolchains() -> ToolchainReport {
1822 let checks = [
1823 ToolCheck::new("git", &[CommandProbe::new("git", &["--version"])]),
1824 ToolCheck::new("rustc", &[CommandProbe::new("rustc", &["--version"])]),
1825 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
1826 ToolCheck::new("node", &[CommandProbe::new("node", &["--version"])]),
1827 ToolCheck::new(
1828 "npm",
1829 &[
1830 CommandProbe::new("npm", &["--version"]),
1831 CommandProbe::new("npm.cmd", &["--version"]),
1832 ],
1833 ),
1834 ToolCheck::new(
1835 "pnpm",
1836 &[
1837 CommandProbe::new("pnpm", &["--version"]),
1838 CommandProbe::new("pnpm.cmd", &["--version"]),
1839 ],
1840 ),
1841 ToolCheck::new(
1842 "python",
1843 &[
1844 CommandProbe::new("python", &["--version"]),
1845 CommandProbe::new("python3", &["--version"]),
1846 CommandProbe::new("py", &["-3", "--version"]),
1847 CommandProbe::new("py", &["--version"]),
1848 ],
1849 ),
1850 ToolCheck::new("deno", &[CommandProbe::new("deno", &["--version"])]),
1851 ToolCheck::new("go", &[CommandProbe::new("go", &["version"])]),
1852 ToolCheck::new("dotnet", &[CommandProbe::new("dotnet", &["--version"])]),
1853 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
1854 ];
1855
1856 let mut found = Vec::new();
1857 let mut missing = Vec::new();
1858
1859 for check in checks {
1860 match check.detect() {
1861 Some(version) => found.push((check.label.to_string(), version)),
1862 None => missing.push(check.label.to_string()),
1863 }
1864 }
1865
1866 ToolchainReport { found, missing }
1867}
1868
1869fn collect_package_managers() -> PackageManagerReport {
1870 let checks = [
1871 ToolCheck::new("cargo", &[CommandProbe::new("cargo", &["--version"])]),
1872 ToolCheck::new(
1873 "npm",
1874 &[
1875 CommandProbe::new("npm", &["--version"]),
1876 CommandProbe::new("npm.cmd", &["--version"]),
1877 ],
1878 ),
1879 ToolCheck::new(
1880 "pnpm",
1881 &[
1882 CommandProbe::new("pnpm", &["--version"]),
1883 CommandProbe::new("pnpm.cmd", &["--version"]),
1884 ],
1885 ),
1886 ToolCheck::new(
1887 "pip",
1888 &[
1889 CommandProbe::new("python", &["-m", "pip", "--version"]),
1890 CommandProbe::new("python3", &["-m", "pip", "--version"]),
1891 CommandProbe::new("py", &["-3", "-m", "pip", "--version"]),
1892 CommandProbe::new("py", &["-m", "pip", "--version"]),
1893 CommandProbe::new("pip", &["--version"]),
1894 ],
1895 ),
1896 ToolCheck::new("pipx", &[CommandProbe::new("pipx", &["--version"])]),
1897 ToolCheck::new("uv", &[CommandProbe::new("uv", &["--version"])]),
1898 ToolCheck::new("winget", &[CommandProbe::new("winget", &["--version"])]),
1899 ToolCheck::new(
1900 "choco",
1901 &[
1902 CommandProbe::new("choco", &["--version"]),
1903 CommandProbe::new("choco.exe", &["--version"]),
1904 ],
1905 ),
1906 ToolCheck::new("scoop", &[CommandProbe::new("scoop", &["--version"])]),
1907 ];
1908
1909 let mut found = Vec::new();
1910 for check in checks {
1911 match check.detect() {
1912 Some(version) => found.push((check.label.to_string(), version)),
1913 None => {}
1914 }
1915 }
1916
1917 PackageManagerReport { found }
1918}
1919
1920#[derive(Clone)]
1921struct ToolCheck {
1922 label: &'static str,
1923 probes: Vec<CommandProbe>,
1924}
1925
1926impl ToolCheck {
1927 fn new(label: &'static str, probes: &[CommandProbe]) -> Self {
1928 Self {
1929 label,
1930 probes: probes.to_vec(),
1931 }
1932 }
1933
1934 fn detect(&self) -> Option<String> {
1935 for probe in &self.probes {
1936 if let Some(output) = capture_first_line(probe.program, probe.args) {
1937 return Some(output);
1938 }
1939 }
1940 None
1941 }
1942}
1943
1944#[derive(Clone, Copy)]
1945struct CommandProbe {
1946 program: &'static str,
1947 args: &'static [&'static str],
1948}
1949
1950impl CommandProbe {
1951 const fn new(program: &'static str, args: &'static [&'static str]) -> Self {
1952 Self { program, args }
1953 }
1954}
1955
1956fn build_env_doctor_findings(
1957 toolchains: &ToolchainReport,
1958 package_managers: &PackageManagerReport,
1959 path_stats: &PathAnalysis,
1960) -> Vec<String> {
1961 let found_tools = toolchains
1962 .found
1963 .iter()
1964 .map(|(label, _)| label.as_str())
1965 .collect::<HashSet<_>>();
1966 let found_managers = package_managers
1967 .found
1968 .iter()
1969 .map(|(label, _)| label.as_str())
1970 .collect::<HashSet<_>>();
1971
1972 let mut findings = Vec::new();
1973
1974 if path_stats.duplicate_entries.len() > 0 {
1975 findings.push(format!(
1976 "PATH contains {} duplicate entries. That is usually harmless but worth cleaning up.",
1977 path_stats.duplicate_entries.len()
1978 ));
1979 }
1980 if path_stats.missing_entries.len() > 0 {
1981 findings.push(format!(
1982 "PATH contains {} entries that do not exist on disk.",
1983 path_stats.missing_entries.len()
1984 ));
1985 }
1986 if found_tools.contains("rustc") && !found_managers.contains("cargo") {
1987 findings.push(
1988 "Rust is present but Cargo was not detected. That is an incomplete Rust toolchain."
1989 .to_string(),
1990 );
1991 }
1992 if found_tools.contains("node")
1993 && !found_managers.contains("npm")
1994 && !found_managers.contains("pnpm")
1995 {
1996 findings.push(
1997 "Node is present but no JavaScript package manager was detected (npm or pnpm)."
1998 .to_string(),
1999 );
2000 }
2001 if found_tools.contains("python")
2002 && !found_managers.contains("pip")
2003 && !found_managers.contains("uv")
2004 && !found_managers.contains("pipx")
2005 {
2006 findings.push(
2007 "Python is present but no Python package manager was detected (pip, uv, or pipx)."
2008 .to_string(),
2009 );
2010 }
2011 let windows_manager_count = ["winget", "choco", "scoop"]
2012 .iter()
2013 .filter(|label| found_managers.contains(**label))
2014 .count();
2015 if windows_manager_count > 1 {
2016 findings.push(
2017 "Multiple Windows package managers are installed. That is workable, but it can create overlap in update paths."
2018 .to_string(),
2019 );
2020 }
2021 if findings.is_empty() && !found_managers.is_empty() {
2022 findings.push(
2023 "Core package-manager coverage looks healthy for a normal developer workstation."
2024 .to_string(),
2025 );
2026 }
2027
2028 findings
2029}
2030
2031fn capture_first_line(program: &str, args: &[&str]) -> Option<String> {
2032 let output = std::process::Command::new(program)
2033 .args(args)
2034 .output()
2035 .ok()?;
2036 if !output.status.success() {
2037 return None;
2038 }
2039
2040 let stdout = if output.stdout.is_empty() {
2041 String::from_utf8_lossy(&output.stderr).into_owned()
2042 } else {
2043 String::from_utf8_lossy(&output.stdout).into_owned()
2044 };
2045
2046 stdout
2047 .lines()
2048 .map(str::trim)
2049 .find(|line| !line.is_empty())
2050 .map(|line| line.to_string())
2051}
2052
2053fn human_bytes(bytes: u64) -> String {
2054 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
2055 let mut value = bytes as f64;
2056 let mut unit_index = 0usize;
2057
2058 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
2059 value /= 1024.0;
2060 unit_index += 1;
2061 }
2062
2063 if unit_index == 0 {
2064 format!("{} {}", bytes, UNITS[unit_index])
2065 } else {
2066 format!("{value:.1} {}", UNITS[unit_index])
2067 }
2068}
2069
2070#[cfg(target_os = "windows")]
2071fn parse_windows_ipconfig_all(text: &str) -> Vec<NetworkAdapter> {
2072 let mut adapters = Vec::new();
2073 let mut current: Option<NetworkAdapter> = None;
2074 let mut pending_dns = false;
2075
2076 for raw_line in text.lines() {
2077 let line = raw_line.trim_end();
2078 let trimmed = line.trim();
2079 if trimmed.is_empty() {
2080 pending_dns = false;
2081 continue;
2082 }
2083
2084 if !line.starts_with(' ') && trimmed.ends_with(':') && trimmed.contains("adapter") {
2085 if let Some(adapter) = current.take() {
2086 adapters.push(adapter);
2087 }
2088 current = Some(NetworkAdapter {
2089 name: trimmed.trim_end_matches(':').to_string(),
2090 ..NetworkAdapter::default()
2091 });
2092 pending_dns = false;
2093 continue;
2094 }
2095
2096 let Some(adapter) = current.as_mut() else {
2097 continue;
2098 };
2099
2100 if trimmed.contains("Media State") && trimmed.contains("disconnected") {
2101 adapter.disconnected = true;
2102 }
2103
2104 if let Some(value) = value_after_colon(trimmed) {
2105 let normalized = normalize_ipconfig_value(value);
2106 if trimmed.starts_with("IPv4 Address") && !normalized.is_empty() {
2107 adapter.ipv4.push(normalized);
2108 pending_dns = false;
2109 } else if trimmed.starts_with("IPv6 Address")
2110 || trimmed.starts_with("Temporary IPv6 Address")
2111 || trimmed.starts_with("Link-local IPv6 Address")
2112 {
2113 if !normalized.is_empty() {
2114 adapter.ipv6.push(normalized);
2115 }
2116 pending_dns = false;
2117 } else if trimmed.starts_with("Default Gateway") {
2118 if !normalized.is_empty() {
2119 adapter.gateways.push(normalized);
2120 }
2121 pending_dns = false;
2122 } else if trimmed.starts_with("DNS Servers") {
2123 if !normalized.is_empty() {
2124 adapter.dns_servers.push(normalized);
2125 }
2126 pending_dns = true;
2127 } else {
2128 pending_dns = false;
2129 }
2130 } else if pending_dns {
2131 let normalized = normalize_ipconfig_value(trimmed);
2132 if !normalized.is_empty() {
2133 adapter.dns_servers.push(normalized);
2134 }
2135 }
2136 }
2137
2138 if let Some(adapter) = current.take() {
2139 adapters.push(adapter);
2140 }
2141
2142 for adapter in &mut adapters {
2143 dedup_vec(&mut adapter.ipv4);
2144 dedup_vec(&mut adapter.ipv6);
2145 dedup_vec(&mut adapter.gateways);
2146 dedup_vec(&mut adapter.dns_servers);
2147 }
2148
2149 adapters
2150}
2151
2152#[cfg(not(target_os = "windows"))]
2153fn parse_unix_ip_addr(text: &str) -> Vec<NetworkAdapter> {
2154 let mut adapters = std::collections::BTreeMap::<String, NetworkAdapter>::new();
2155
2156 for line in text.lines() {
2157 let cols: Vec<&str> = line.split_whitespace().collect();
2158 if cols.len() < 4 {
2159 continue;
2160 }
2161 let name = cols[1].trim_end_matches(':').to_string();
2162 let family = cols[2];
2163 let addr = cols[3].split('/').next().unwrap_or("").to_string();
2164 let entry = adapters
2165 .entry(name.clone())
2166 .or_insert_with(|| NetworkAdapter {
2167 name,
2168 ..NetworkAdapter::default()
2169 });
2170 match family {
2171 "inet" if !addr.is_empty() => entry.ipv4.push(addr),
2172 "inet6" if !addr.is_empty() => entry.ipv6.push(addr),
2173 _ => {}
2174 }
2175 }
2176
2177 adapters.into_values().collect()
2178}
2179
2180#[cfg(not(target_os = "windows"))]
2181fn apply_unix_default_routes(adapters: &mut [NetworkAdapter], text: &str) {
2182 for line in text.lines() {
2183 let cols: Vec<&str> = line.split_whitespace().collect();
2184 if cols.len() < 5 {
2185 continue;
2186 }
2187 let gateway = cols
2188 .windows(2)
2189 .find(|pair| pair[0] == "via")
2190 .map(|pair| pair[1].to_string());
2191 let dev = cols
2192 .windows(2)
2193 .find(|pair| pair[0] == "dev")
2194 .map(|pair| pair[1]);
2195 if let (Some(gateway), Some(dev)) = (gateway, dev) {
2196 if let Some(adapter) = adapters.iter_mut().find(|adapter| adapter.name == dev) {
2197 adapter.gateways.push(gateway);
2198 }
2199 }
2200 }
2201
2202 for adapter in adapters {
2203 dedup_vec(&mut adapter.gateways);
2204 }
2205}
2206
2207#[cfg(not(target_os = "windows"))]
2208fn apply_unix_dns_servers(adapters: &mut [NetworkAdapter]) {
2209 let Ok(text) = fs::read_to_string("/etc/resolv.conf") else {
2210 return;
2211 };
2212 let mut dns_servers = text
2213 .lines()
2214 .filter_map(|line| line.strip_prefix("nameserver "))
2215 .map(str::trim)
2216 .filter(|value| !value.is_empty())
2217 .map(|value| value.to_string())
2218 .collect::<Vec<_>>();
2219 dedup_vec(&mut dns_servers);
2220 if dns_servers.is_empty() {
2221 return;
2222 }
2223 for adapter in adapters.iter_mut().filter(|adapter| adapter.is_active()) {
2224 adapter.dns_servers = dns_servers.clone();
2225 }
2226}
2227
2228#[cfg(target_os = "windows")]
2229fn parse_tasklist_memory_kib(raw: &str) -> Option<u64> {
2230 let digits: String = raw.chars().filter(|ch| ch.is_ascii_digit()).collect();
2231 if digits.is_empty() {
2232 None
2233 } else {
2234 digits.parse::<u64>().ok()
2235 }
2236}
2237
2238#[cfg(target_os = "windows")]
2239fn parse_csv_row(line: &str) -> Vec<String> {
2240 let mut cols = Vec::new();
2241 let mut current = String::new();
2242 let mut in_quotes = false;
2243 let mut chars = line.chars().peekable();
2244
2245 while let Some(ch) = chars.next() {
2246 match ch {
2247 '"' => {
2248 if in_quotes && chars.peek() == Some(&'"') {
2249 current.push('"');
2250 chars.next();
2251 } else {
2252 in_quotes = !in_quotes;
2253 }
2254 }
2255 ',' if !in_quotes => {
2256 cols.push(current.trim().to_string());
2257 current.clear();
2258 }
2259 _ => current.push(ch),
2260 }
2261 }
2262 cols.push(current.trim().to_string());
2263 cols
2264}
2265
2266fn value_after_colon(line: &str) -> Option<&str> {
2267 line.split_once(':').map(|(_, value)| value.trim())
2268}
2269
2270fn normalize_ipconfig_value(value: &str) -> String {
2271 value
2272 .trim()
2273 .trim_matches(['(', ')'])
2274 .trim_end_matches("(Preferred)")
2275 .trim()
2276 .to_string()
2277}
2278
2279fn dedup_vec(values: &mut Vec<String>) {
2280 let mut seen = HashSet::new();
2281 values.retain(|value| seen.insert(value.clone()));
2282}
2283
2284#[cfg(target_os = "windows")]
2285fn parse_windows_services_json(text: &str) -> Result<Vec<ServiceEntry>, String> {
2286 let trimmed = text.trim();
2287 if trimmed.is_empty() {
2288 return Ok(Vec::new());
2289 }
2290
2291 let value: Value = serde_json::from_str(trimmed)
2292 .map_err(|e| format!("Failed to parse PowerShell service JSON: {e}"))?;
2293 let entries = match value {
2294 Value::Array(items) => items,
2295 other => vec![other],
2296 };
2297
2298 let mut services = Vec::new();
2299 for entry in entries {
2300 let Some(name) = entry.get("Name").and_then(|v| v.as_str()) else {
2301 continue;
2302 };
2303 services.push(ServiceEntry {
2304 name: name.to_string(),
2305 status: entry
2306 .get("State")
2307 .and_then(|v| v.as_str())
2308 .unwrap_or("unknown")
2309 .to_string(),
2310 startup: entry
2311 .get("StartMode")
2312 .and_then(|v| v.as_str())
2313 .map(|value| value.to_string()),
2314 display_name: entry
2315 .get("DisplayName")
2316 .and_then(|v| v.as_str())
2317 .map(|value| value.to_string()),
2318 });
2319 }
2320
2321 Ok(services)
2322}
2323
2324#[cfg(not(target_os = "windows"))]
2325fn parse_unix_services(status_text: &str, startup_text: &str) -> Vec<ServiceEntry> {
2326 let mut startup_modes = std::collections::HashMap::<String, String>::new();
2327 for line in startup_text.lines() {
2328 let cols: Vec<&str> = line.split_whitespace().collect();
2329 if cols.len() < 2 {
2330 continue;
2331 }
2332 startup_modes.insert(cols[0].to_string(), cols[1].to_string());
2333 }
2334
2335 let mut services = Vec::new();
2336 for line in status_text.lines() {
2337 let cols: Vec<&str> = line.split_whitespace().collect();
2338 if cols.len() < 4 {
2339 continue;
2340 }
2341 let unit = cols[0];
2342 let load = cols[1];
2343 let active = cols[2];
2344 let sub = cols[3];
2345 let description = if cols.len() > 4 {
2346 Some(cols[4..].join(" "))
2347 } else {
2348 None
2349 };
2350 services.push(ServiceEntry {
2351 name: unit.to_string(),
2352 status: format!("{}/{}", active, sub),
2353 startup: startup_modes
2354 .get(unit)
2355 .cloned()
2356 .or_else(|| Some(load.to_string())),
2357 display_name: description,
2358 });
2359 }
2360
2361 services
2362}