Skip to main content

hematite/tools/
host_inspect.rs

1use serde_json::Value;
2use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7const DEFAULT_MAX_ENTRIES: usize = 10;
8const MAX_ENTRIES_CAP: usize = 25;
9const DIRECTORY_SCAN_NODE_BUDGET: usize = 25_000;
10
11pub async fn inspect_host(args: &Value) -> Result<String, String> {
12    let topic = args
13        .get("topic")
14        .and_then(|v| v.as_str())
15        .unwrap_or("summary");
16    let max_entries = parse_max_entries(args);
17
18    match topic {
19        "summary" => inspect_summary(max_entries),
20        "toolchains" => inspect_toolchains(),
21        "path" => inspect_path(max_entries),
22        "env_doctor" => inspect_env_doctor(max_entries),
23        "fix_plan" => inspect_fix_plan(parse_issue_text(args), parse_port_filter(args), max_entries).await,
24        "network" => inspect_network(max_entries),
25        "services" => inspect_services(parse_name_filter(args), max_entries),
26        "processes" => inspect_processes(parse_name_filter(args), max_entries),
27        "desktop" => inspect_known_directory("Desktop", desktop_dir(), max_entries).await,
28        "downloads" => inspect_known_directory("Downloads", downloads_dir(), max_entries).await,
29        "disk" => {
30            let path = resolve_optional_path(args)?;
31            inspect_disk(path, max_entries).await
32        }
33        "ports" => inspect_ports(parse_port_filter(args), max_entries),
34        "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}