Skip to main content

hematite/agent/
report_export.rs

1use serde_json::json;
2use std::fmt::Write as _;
3use std::path::{Path, PathBuf};
4
5const REPORT_TOPICS: &[(&str, &str)] = &[
6    ("health_report", "System Health"),
7    ("hardware", "Hardware"),
8    ("storage", "Storage"),
9    ("network", "Network"),
10    ("security", "Security"),
11    ("toolchains", "Developer Toolchains"),
12];
13
14/// IT-first-look triage topics (health, security, connectivity, identity, updates).
15const TRIAGE_TOPICS: &[(&str, &str)] = &[
16    ("health_report", "System Health"),
17    ("security", "Security Posture"),
18    ("connectivity", "Connectivity"),
19    ("identity_auth", "Identity & Auth (M365/AAD)"),
20    ("updates", "Windows Updates"),
21];
22
23fn triage_topics_for_preset(preset: &str) -> &'static [(&'static str, &'static str)] {
24    match preset {
25        "network" => &[
26            ("connectivity", "Connectivity"),
27            ("wifi", "Wi-Fi"),
28            ("latency", "Latency"),
29            ("dns_servers", "DNS Servers"),
30            ("vpn", "VPN"),
31            ("proxy", "Proxy"),
32            ("connections", "Active Connections"),
33        ],
34        "security" => &[
35            ("security", "Security Posture"),
36            ("bitlocker", "BitLocker"),
37            ("tpm", "TPM / Secure Boot"),
38            ("local_security_policy", "Local Security Policy"),
39            ("shares", "SMB Shares"),
40            ("print_spooler", "Print Spooler"),
41        ],
42        "performance" => &[
43            ("resource_load", "Resource Load"),
44            ("thermal", "Thermal"),
45            ("cpu_power", "CPU Power"),
46            ("processes", "Top Processes"),
47            ("pagefile", "Page File"),
48            ("startup_items", "Startup Items"),
49        ],
50        "storage" => &[
51            ("storage", "Storage"),
52            ("disk_health", "Disk Health"),
53            ("shadow_copies", "Shadow Copies"),
54            ("storage_spaces", "Storage Spaces"),
55            ("bitlocker", "BitLocker"),
56        ],
57        "apps" => &[
58            ("browser_health", "Browser Health"),
59            ("outlook", "Outlook"),
60            ("teams", "Teams"),
61            ("installer_health", "Installer Health"),
62            ("onedrive", "OneDrive"),
63        ],
64        _ => TRIAGE_TOPICS,
65    }
66}
67
68fn triage_preset_title(preset: &str) -> &'static str {
69    match preset {
70        "network" => "Hematite Network Triage Report",
71        "security" => "Hematite Security Triage Report",
72        "performance" => "Hematite Performance Triage Report",
73        "storage" => "Hematite Storage Triage Report",
74        "apps" => "Hematite App Health Triage Report",
75        _ => "Hematite IT Triage Report",
76    }
77}
78
79/// Map a plain-English issue description to the most relevant inspect_host topics.
80fn topics_for_issue(issue: &str) -> Vec<(&'static str, &'static str)> {
81    let lower = issue.to_ascii_lowercase();
82    let mut seen = std::collections::HashSet::new();
83    let mut topics: Vec<(&'static str, &'static str)> = Vec::new();
84
85    macro_rules! add_if {
86        ($keywords:expr, $pairs:expr) => {
87            if $keywords.iter().any(|k: &&str| lower.contains(k)) {
88                for &pair in $pairs {
89                    if seen.insert(pair.0) {
90                        topics.push(pair);
91                    }
92                }
93            }
94        };
95    }
96
97    add_if!(
98        &[
99            "slow",
100            "lag",
101            "freeze",
102            "hang",
103            "sluggish",
104            "unresponsive",
105            "performance",
106            "high cpu",
107            "high ram",
108            "high memory",
109            "locking up"
110        ],
111        &[
112            ("resource_load", "Resource Load"),
113            ("thermal", "Thermal"),
114            ("cpu_power", "CPU Power"),
115            ("pagefile", "Page File"),
116            ("startup_items", "Startup Items")
117        ]
118    );
119    add_if!(
120        &[
121            "internet",
122            "network",
123            "wifi",
124            "wi-fi",
125            "wireless",
126            "offline",
127            "no web",
128            "can't browse",
129            "ping fails",
130            "no connection",
131            "can't connect"
132        ],
133        &[
134            ("connectivity", "Connectivity"),
135            ("wifi", "Wi-Fi"),
136            ("latency", "Latency"),
137            ("dns_servers", "DNS Servers")
138        ]
139    );
140    add_if!(
141        &["dns ", "dns:", "name resolution", "can't resolve"],
142        &[
143            ("dns_servers", "DNS Servers"),
144            ("connectivity", "Connectivity")
145        ]
146    );
147    add_if!(
148        &["vpn ", "vpn:", "tunnel", "remote access"],
149        &[
150            ("vpn", "VPN"),
151            ("connectivity", "Connectivity"),
152            ("proxy", "Proxy")
153        ]
154    );
155    add_if!(
156        &[
157            "disk full",
158            "out of space",
159            "low disk",
160            "disk space",
161            "drive full",
162            "storage full",
163            "no space"
164        ],
165        &[
166            ("storage", "Storage"),
167            ("disk_health", "Disk Health"),
168            ("shadow_copies", "Shadow Copies")
169        ]
170    );
171    add_if!(
172        &[
173            "disk fail",
174            "drive fail",
175            "smart error",
176            "disk error",
177            "bad sector",
178            "drive health"
179        ],
180        &[("disk_health", "Disk Health"), ("storage", "Storage")]
181    );
182    add_if!(
183        &[
184            "slow boot",
185            "boot slow",
186            "slow startup",
187            "startup slow",
188            "takes forever to boot"
189        ],
190        &[
191            ("startup_items", "Startup Items"),
192            ("services", "Services"),
193            ("disk_health", "Disk Health")
194        ]
195    );
196    add_if!(
197        &[
198            "crash",
199            "bsod",
200            "blue screen",
201            "unexpected restart",
202            "unexpected shutdown",
203            "kernel panic",
204            "stop error"
205        ],
206        &[
207            ("recent_crashes", "Crash History"),
208            ("log_check", "Event Log"),
209            ("thermal", "Thermal"),
210            ("disk_health", "Disk Health")
211        ]
212    );
213    add_if!(
214        &[
215            "app crash",
216            "application crash",
217            "program crash",
218            "program not opening",
219            "app not starting",
220            "not responding",
221            "application error"
222        ],
223        &[
224            ("app_crashes", "Application Crashes"),
225            ("log_check", "Event Log")
226        ]
227    );
228    add_if!(
229        &[
230            "update",
231            "windows update",
232            "patch",
233            "stuck on update",
234            "update fail"
235        ],
236        &[
237            ("updates", "Windows Updates"),
238            ("pending_reboot", "Pending Reboot"),
239            ("services", "Services")
240        ]
241    );
242    add_if!(
243        &[
244            "virus",
245            "malware",
246            "hacked",
247            "suspicious",
248            "threat",
249            "infected",
250            "ransomware"
251        ],
252        &[
253            ("security", "Security Posture"),
254            ("defender_quarantine", "Defender Quarantine"),
255            ("log_check", "Event Log")
256        ]
257    );
258    add_if!(
259        &[
260            "firewall",
261            "blocked port",
262            "blocked connection",
263            "port block"
264        ],
265        &[
266            ("security", "Security Posture"),
267            ("firewall_rules", "Firewall Rules")
268        ]
269    );
270    add_if!(
271        &[
272            "printer",
273            "printing",
274            "print queue",
275            "can't print",
276            "print fail"
277        ],
278        &[
279            ("printers", "Printers"),
280            ("print_spooler", "Print Spooler"),
281            ("drivers", "Drivers")
282        ]
283    );
284    add_if!(
285        &[
286            "sound",
287            "audio",
288            "speaker",
289            "no sound",
290            "headset",
291            "mic",
292            "microphone",
293            "crackling",
294            "audio fail"
295        ],
296        &[("audio", "Audio")]
297    );
298    add_if!(
299        &[
300            "bluetooth",
301            "headphones",
302            "airpods",
303            "wireless headset",
304            "bt "
305        ],
306        &[("bluetooth", "Bluetooth"), ("audio", "Audio")]
307    );
308    add_if!(
309        &[
310            "camera",
311            "webcam",
312            "video call",
313            "camera not working",
314            "can't see camera"
315        ],
316        &[("camera", "Camera")]
317    );
318    add_if!(
319        &["teams", "microsoft teams"],
320        &[
321            ("teams", "Teams"),
322            ("identity_auth", "Identity & Auth"),
323            ("browser_health", "Browser Health")
324        ]
325    );
326    add_if!(
327        &["outlook", "email not working", "mail not", "calendar not"],
328        &[("outlook", "Outlook"), ("identity_auth", "Identity & Auth")]
329    );
330    add_if!(
331        &[
332            "browser",
333            "chrome",
334            "edge ",
335            "firefox",
336            "slow browser",
337            "browser crash",
338            "browser not"
339        ],
340        &[("browser_health", "Browser Health")]
341    );
342    add_if!(
343        &[
344            "sign in",
345            "can't log in",
346            "login fail",
347            "password",
348            "pin not working",
349            "fingerprint",
350            "hello not",
351            "locked out",
352            "authentication fail"
353        ],
354        &[
355            ("sign_in", "Sign-In / Windows Hello"),
356            ("identity_auth", "Identity & Auth"),
357            ("credentials", "Credentials")
358        ]
359    );
360    add_if!(
361        &[
362            "rdp",
363            "remote desktop",
364            "can't connect remotely",
365            "remote desktop not"
366        ],
367        &[
368            ("rdp", "Remote Desktop"),
369            ("connectivity", "Connectivity"),
370            ("firewall_rules", "Firewall Rules")
371        ]
372    );
373    add_if!(
374        &[
375            "device not recognized",
376            "driver not",
377            "usb not working",
378            "device problem",
379            "yellow bang",
380            "hardware not"
381        ],
382        &[
383            ("device_health", "Device Health"),
384            ("drivers", "Drivers"),
385            ("peripherals", "Peripherals")
386        ]
387    );
388    add_if!(
389        &[
390            "time wrong",
391            "clock wrong",
392            "wrong time",
393            "time sync",
394            "time off"
395        ],
396        &[("ntp", "NTP / Time Sync")]
397    );
398    add_if!(
399        &[
400            "onedrive",
401            "one drive",
402            "file sync",
403            "not syncing",
404            "sync fail"
405        ],
406        &[("onedrive", "OneDrive")]
407    );
408    add_if!(
409        &["wmi error", "powershell wmi", "get-wmiobject fail"],
410        &[("wmi_health", "WMI Health")]
411    );
412    add_if!(
413        &[
414            "monitor",
415            "display",
416            "screen resolution",
417            "second monitor",
418            "wrong resolution",
419            "display settings",
420            "refresh rate",
421            "scaling"
422        ],
423        &[("display_config", "Display Config")]
424    );
425    add_if!(
426        &[
427            "keyboard not",
428            "keyboard stop",
429            "keyboard broke",
430            "mouse not",
431            "mouse stop",
432            "mouse broke",
433            "touchpad",
434            "trackpad",
435            "peripheral not"
436        ],
437        &[
438            ("peripherals", "Peripherals"),
439            ("device_health", "Device Health")
440        ]
441    );
442    add_if!(
443        &[
444            "hibernate",
445            "won't hibernate",
446            "sleep issue",
447            "won't sleep",
448            "won't wake",
449            "stuck after sleep",
450            "won't wake up",
451            "sleep mode"
452        ],
453        &[
454            ("pending_reboot", "Pending Reboot"),
455            ("services", "Services"),
456            ("thermal", "Thermal")
457        ]
458    );
459    add_if!(
460        &[
461            "microsoft store",
462            "store app",
463            "windows store",
464            "uwp",
465            "app won't install",
466            "store not working",
467            "winget"
468        ],
469        &[("installer_health", "Installer Health")]
470    );
471    add_if!(
472        &[
473            "no sound",
474            "audio not",
475            "sound not",
476            "speaker not",
477            "microphone not",
478            "mic not",
479            "audio stopped",
480            "crackling",
481            "no audio"
482        ],
483        &[("audio", "Audio")]
484    );
485    add_if!(
486        &[
487            "bluetooth not",
488            "bluetooth won't",
489            "headset won't connect",
490            "headphones won't",
491            "can't pair",
492            "won't pair",
493            "bluetooth disconnect",
494            "bluetooth keep"
495        ],
496        &[("bluetooth", "Bluetooth")]
497    );
498    add_if!(
499        &[
500            "outlook not",
501            "outlook won't",
502            "outlook crash",
503            "outlook slow",
504            "email not",
505            "email won't",
506            "email crash",
507            "calendar not",
508            "pst",
509            "ost file"
510        ],
511        &[("outlook", "Outlook")]
512    );
513    add_if!(
514        &[
515            "teams not",
516            "teams won't",
517            "teams crash",
518            "teams slow",
519            "teams black screen",
520            "teams audio",
521            "teams video",
522            "microsoft teams"
523        ],
524        &[("teams", "Teams")]
525    );
526    add_if!(
527        &[
528            "chrome slow",
529            "chrome crash",
530            "edge slow",
531            "edge crash",
532            "firefox slow",
533            "firefox crash",
534            "browser slow",
535            "browser crash",
536            "browser not",
537            "browser keeps"
538        ],
539        &[("browser_health", "Browser Health")]
540    );
541
542    if topics.is_empty() {
543        topics.push(("health_report", "System Health"));
544        topics.push(("log_check", "Event Log"));
545    }
546    topics
547}
548
549pub fn fix_plan_topics(issue: &str) -> Vec<(&'static str, &'static str)> {
550    topics_for_issue(issue)
551}
552
553struct AutoCmdAc {
554    ac: aho_corasick::AhoCorasick,
555    entries: Vec<(&'static str, &'static str)>,
556}
557
558static AUTO_CMD_AC: std::sync::OnceLock<AutoCmdAc> = std::sync::OnceLock::new();
559
560fn auto_cmd_ac() -> &'static AutoCmdAc {
561    AUTO_CMD_AC.get_or_init(|| {
562        const SAFE: &[(&str, &str, &str)] = &[
563            ("dns: failed", "Flush DNS cache", "ipconfig /flushdns"),
564            (
565                "dns resolution: failed",
566                "Flush DNS cache",
567                "ipconfig /flushdns",
568            ),
569            (
570                "wsearch",
571                "Restart Windows Search",
572                "powershell -Command \"Restart-Service WSearch -ErrorAction SilentlyContinue\"",
573            ),
574            (
575                "windows search",
576                "Restart Windows Search",
577                "powershell -Command \"Restart-Service WSearch -ErrorAction SilentlyContinue\"",
578            ),
579            (
580                "spooler",
581                "Restart Print Spooler",
582                "powershell -Command \"Restart-Service Spooler -Force\"",
583            ),
584            (
585                "print spooler",
586                "Restart Print Spooler",
587                "powershell -Command \"Restart-Service Spooler -Force\"",
588            ),
589            (
590                "ntp source unreachable",
591                "Resync system clock",
592                "w32tm /resync /force",
593            ),
594            (
595                "time sync failed",
596                "Resync system clock",
597                "w32tm /resync /force",
598            ),
599            (
600                "bits",
601                "Restart BITS service",
602                "powershell -Command \"Restart-Service BITS -Force\"",
603            ),
604            (
605                "wuauserv",
606                "Restart Windows Update service",
607                "powershell -Command \"Restart-Service wuauserv -Force\"",
608            ),
609            (
610                "windows update service",
611                "Restart Windows Update service",
612                "powershell -Command \"Restart-Service wuauserv -Force\"",
613            ),
614            (
615                "audiosrv",
616                "Restart Audio service",
617                "powershell -Command \"Restart-Service Audiosrv -Force\"",
618            ),
619            (
620                "windows audio",
621                "Restart Audio service",
622                "powershell -Command \"Restart-Service Audiosrv -Force\"",
623            ),
624            (
625                "low disk",
626                "Empty Recycle Bin",
627                "powershell -Command \"Clear-RecycleBin -Force -ErrorAction SilentlyContinue\"",
628            ),
629            (
630                "free up space",
631                "Empty Recycle Bin",
632                "powershell -Command \"Clear-RecycleBin -Force -ErrorAction SilentlyContinue\"",
633            ),
634        ];
635        let mut patterns: Vec<&str> = Vec::with_capacity(SAFE.len());
636        let mut entries: Vec<(&'static str, &'static str)> = Vec::with_capacity(SAFE.len());
637        for &(trigger, label, cmd) in SAFE {
638            patterns.push(trigger);
639            entries.push((label, cmd));
640        }
641        AutoCmdAc {
642            ac: aho_corasick::AhoCorasick::new(&patterns).expect("valid patterns"),
643            entries,
644        }
645    })
646}
647
648pub fn fix_plan_auto_commands(combined_output: &str) -> Vec<(&'static str, &'static str)> {
649    let lower = combined_output.to_ascii_lowercase();
650    let state = auto_cmd_ac();
651    let mut seen_labels = std::collections::HashSet::new();
652    let mut result: Vec<(&'static str, &'static str)> = Vec::new();
653    for mat in state.ac.find_iter(&lower) {
654        let (label, cmd) = state.entries[mat.pattern().as_usize()];
655        if seen_labels.insert(label) {
656            result.push((label, cmd));
657        }
658    }
659    result
660}
661
662/// Map a recipe title to the most natural `hematite --fix "<issue>"` argument.
663/// Returns `None` for recipes that don't have a clean fix-command equivalent.
664fn recipe_title_to_fix_arg(title: &str) -> Option<&'static str> {
665    match title {
666        t if t.contains("disk space") || t.contains("Low disk") => Some("disk full"),
667        t if t.contains("Drive health") || t.contains("failure") => Some("disk health warning"),
668        t if t.contains("Restart required") => Some("restart required"),
669        t if t.contains("event log errors") => Some("Windows errors in event log"),
670        t if t.contains("service not running") => Some("critical service stopped"),
671        t if t.contains("No internet") => Some("can't connect to internet"),
672        t if t.contains("latency") => Some("high network latency"),
673        t if t.contains("memory usage") => Some("high RAM usage"),
674        t if t.contains("running hot") || t.contains("CPU") => Some("CPU running hot"),
675        t if t.contains("security protection") => Some("Windows Defender disabled"),
676        t if t.contains("Threat detected") => Some("virus or malware detected"),
677        t if t.contains("updates pending") => Some("Windows updates pending"),
678        t if t.contains("Hardware device error") => Some("hardware device error"),
679        t if t.contains("No backup") => Some("no backup configured"),
680        t if t.contains("SMB1") => Some("SMB1 security risk"),
681        t if t.contains("encryption not enabled") => Some("BitLocker not enabled"),
682        t if t.contains("DNS resolution") => Some("DNS not resolving"),
683        t if t.contains("Application crashing") => Some("app crashing repeatedly"),
684        t if t.contains("Wi-Fi signal weak") => Some("weak Wi-Fi signal"),
685        t if t.contains("clock not synchronizing") => Some("clock out of sync"),
686        t if t.contains("system file corruption") => Some("Windows system files corrupt"),
687        t if t.contains("Service failed") => Some("service stopped unexpectedly"),
688        t if t.contains("Remote Desktop") => Some("RDP disabled or blocked"),
689        t if t.contains("Windows Update service") => Some("Windows Update broken"),
690        t if t.contains("Teams cache") => Some("Teams not working"),
691        t if t.contains("authentication broker") => Some("Microsoft 365 sign-in broken"),
692        t if t.contains("WMI repository") => Some("WMI errors"),
693        t if t.contains("Windows not activated") => Some("Windows not activated"),
694        t if t.contains("Windows Search") => Some("Windows search not finding files"),
695        t if t.contains("OneDrive not syncing") => Some("OneDrive not syncing"),
696        t if t.contains("Printer offline") => Some("printer offline or stuck"),
697        t if t.contains("Outlook mail profile") => Some("Outlook not opening"),
698        t if t.contains("PrintNightmare") => Some("PrintNightmare not mitigated"),
699        _ => None,
700    }
701}
702
703/// Given the combined plain-text content of a triage or diagnose report, return
704/// a deduplicated list of `hematite --fix "<issue>"` suggestions for the IT tech.
705/// Only ACTION and INVESTIGATE severity recipes are surfaced.
706pub fn suggest_fix_commands(content: &str) -> Vec<String> {
707    let recipes = crate::agent::fix_recipes::match_recipes(content);
708    let mut seen = std::collections::HashSet::new();
709    let mut suggestions: Vec<String> = Vec::new();
710    for recipe in recipes {
711        if recipe.severity == "MONITOR" {
712            continue;
713        }
714        if let Some(arg) = recipe_title_to_fix_arg(recipe.title) {
715            if seen.insert(arg) {
716                suggestions.push(format!("  hematite --fix \"{}\"", arg));
717            }
718        }
719    }
720    suggestions
721}
722
723/// Re-score health from a saved report's plain-text content for terminal display.
724/// Wraps the whole content as a single pseudo-section and runs the recipe matcher.
725pub fn score_health_from_content(content: &str) -> crate::agent::fix_recipes::HealthScore {
726    crate::agent::fix_recipes::score_health(&[("report", content)])
727}
728
729pub fn report_has_issues_in_content(content: &str) -> bool {
730    for line in content.lines() {
731        if line.contains("Health Score:") {
732            if let Some(pos) = line.find("Score:") {
733                let after = line[pos + 6..]
734                    .trim_start()
735                    .trim_start_matches('*')
736                    .trim_start();
737                return !after.starts_with('A');
738            }
739        }
740    }
741    false
742}
743
744pub fn fix_issue_categories() -> &'static [(&'static str, &'static str)] {
745    &[
746        (
747            "Performance",
748            "slow, lag, freeze, hang, high cpu, high ram, unresponsive",
749        ),
750        (
751            "Network",
752            "internet, wifi, offline, no connection, can't browse",
753        ),
754        ("DNS", "dns, name resolution, can't resolve"),
755        ("VPN", "vpn, tunnel, remote access"),
756        (
757            "Disk Space",
758            "disk full, out of space, low disk, drive full",
759        ),
760        (
761            "Disk Health",
762            "disk fail, smart error, bad sector, drive health",
763        ),
764        (
765            "Slow Boot",
766            "slow boot, startup slow, takes forever to boot",
767        ),
768        (
769            "Crash / BSOD",
770            "crash, bsod, blue screen, stop error, kernel panic",
771        ),
772        (
773            "App Crashes",
774            "app crash, not responding, application error",
775        ),
776        (
777            "Windows Update",
778            "update, windows update, patch, stuck on update",
779        ),
780        (
781            "Virus / Malware",
782            "virus, malware, hacked, threat, infected, ransomware",
783        ),
784        ("Firewall", "firewall, blocked port, blocked connection"),
785        ("Printer", "printer, printing, print queue, can't print"),
786        ("Audio", "sound, audio, no sound, speaker, mic, microphone"),
787        ("Bluetooth", "bluetooth, headphones, wireless headset"),
788        ("Camera", "camera, webcam, video call"),
789        ("Teams", "teams, microsoft teams"),
790        (
791            "Outlook / Email",
792            "outlook, email not working, calendar not",
793        ),
794        ("Browser", "browser, chrome, edge, firefox, slow browser"),
795        (
796            "Sign-In / PIN",
797            "sign in, can't log in, pin not working, fingerprint, locked out",
798        ),
799        (
800            "Remote Desktop",
801            "rdp, remote desktop, can't connect remotely",
802        ),
803        (
804            "Driver / Device",
805            "device not recognized, driver not, usb not working, yellow bang",
806        ),
807        ("Clock / Time", "time wrong, clock wrong, time sync"),
808        ("OneDrive", "onedrive, file sync, not syncing"),
809        ("WMI", "wmi error, powershell wmi"),
810        (
811            "Display / Monitor",
812            "monitor, display, screen resolution, second monitor, refresh rate, scaling",
813        ),
814        (
815            "Keyboard / Mouse",
816            "keyboard not working, mouse not working, touchpad, trackpad",
817        ),
818        (
819            "Sleep / Hibernate",
820            "hibernate, won't sleep, won't wake, sleep issue, stuck after sleep",
821        ),
822        (
823            "Microsoft Store / Apps",
824            "microsoft store, store app, uwp, app won't install, winget",
825        ),
826    ]
827}
828
829pub async fn generate_report_markdown() -> String {
830    let timestamp = now_timestamp_string();
831    let mut hostname = hostname_from_env();
832    let version = env!("CARGO_PKG_VERSION");
833    let mut sections: Vec<(&str, String)> = Vec::with_capacity(REPORT_TOPICS.len());
834
835    let total = REPORT_TOPICS.len();
836    for (i, (topic, label)) in REPORT_TOPICS.iter().enumerate() {
837        eprintln!("  [{}/{}] {}...", i + 1, total, label);
838        let args = json!({"topic": topic});
839        let output = match crate::tools::host_inspect::inspect_host(&args).await {
840            Ok(s) => {
841                if *topic == "hardware" {
842                    for line in s.lines() {
843                        let ll = line.to_ascii_lowercase();
844                        if ll.contains("hostname") || ll.contains("computer name") {
845                            if let Some(val) = line.split_once(':').map(|x| x.1) {
846                                let h = val.trim().to_string();
847                                if !h.is_empty() {
848                                    hostname = h;
849                                }
850                            }
851                        }
852                    }
853                }
854                s
855            }
856            Err(e) => format!("Error: {}", e),
857        };
858        sections.push((label, output));
859    }
860
861    let section_refs: Vec<(&str, &str)> = sections.iter().map(|(l, o)| (*l, o.as_str())).collect();
862    let score = crate::agent::fix_recipes::score_health(&section_refs);
863    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
864
865    let mut md = String::with_capacity(action_plan.len() + sections.len() * 512 + 256);
866    md.push_str("# Hematite Diagnostic Report\n\n");
867    let _ = writeln!(md, "**Generated:** {}  ", timestamp);
868    let _ = writeln!(md, "**Host:** {}  ", hostname);
869    let _ = writeln!(md, "**Hematite:** v{}  ", version);
870    let _ = write!(
871        md,
872        "**Health Score:** {} — {}  \n\n",
873        score.grade, score.label
874    );
875    let _ = write!(md, "> {}\n\n", score.summary_line());
876    md.push_str("---\n\n");
877
878    md.push_str("## Action Plan\n\n");
879    md.push_str(&action_plan);
880    md.push_str("---\n\n");
881
882    for (label, output) in &sections {
883        let _ = write!(md, "## {}\n\n", label);
884        md.push_str("```\n");
885        md.push_str(output.trim_end());
886        md.push_str("\n```\n\n");
887    }
888
889    md
890}
891
892struct DiagnosisData {
893    timestamp: String,
894    hostname: String,
895    health_output: String,
896    follow_up_outputs: Vec<(&'static str, String)>,
897}
898
899async fn run_diagnosis_phases() -> DiagnosisData {
900    let timestamp = now_timestamp_string();
901    let hostname = hostname_from_env();
902
903    eprintln!("  → System Health (scanning for issues)...");
904    let health_args = json!({"topic": "health_report"});
905    let health_output = match crate::tools::host_inspect::inspect_host(&health_args).await {
906        Ok(s) => s,
907        Err(e) => format!("Error running health_report: {}", e),
908    };
909
910    let follow_up_topics = crate::agent::diagnose::triage_follow_up_topics(&health_output);
911
912    if follow_up_topics.is_empty() {
913        eprintln!("  → No follow-up checks needed.");
914    } else {
915        eprintln!(
916            "  → {} area(s) flagged — running targeted checks...",
917            follow_up_topics.len()
918        );
919    }
920
921    let mut follow_up_outputs: Vec<(&'static str, String)> =
922        Vec::with_capacity(follow_up_topics.len());
923    for (i, topic) in follow_up_topics.iter().enumerate() {
924        eprintln!("  [{}/{}] {}...", i + 1, follow_up_topics.len(), topic);
925        let args = json!({"topic": topic});
926        let output = match crate::tools::host_inspect::inspect_host(&args).await {
927            Ok(s) => s,
928            Err(e) => format!("Error: {}", e),
929        };
930        follow_up_outputs.push((*topic, output));
931    }
932
933    DiagnosisData {
934        timestamp,
935        hostname,
936        health_output,
937        follow_up_outputs,
938    }
939}
940
941/// Run a full staged diagnosis — health_report → triage → targeted follow-ups → fix recipes.
942/// No TUI, no model required. Output is self-contained markdown for cloud model ingestion.
943pub async fn generate_diagnosis_report() -> String {
944    let version = env!("CARGO_PKG_VERSION");
945    let data = run_diagnosis_phases().await;
946
947    let mut section_refs: Vec<(&str, &str)> = vec![("health_report", data.health_output.as_str())];
948    for (topic, output) in &data.follow_up_outputs {
949        section_refs.push((*topic, output.as_str()));
950    }
951    let score = crate::agent::fix_recipes::score_health(&section_refs);
952    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
953
954    let mut md =
955        String::with_capacity(action_plan.len() + data.follow_up_outputs.len() * 512 + 256);
956    md.push_str("# Hematite Staged Diagnosis Report\n\n");
957    let _ = writeln!(md, "**Generated:** {}  ", data.timestamp);
958    let _ = writeln!(md, "**Host:** {}  ", data.hostname);
959    let _ = writeln!(md, "**Hematite:** v{}  ", version);
960    let _ = write!(
961        md,
962        "**Health Score:** {} — {}  \n\n",
963        score.grade, score.label
964    );
965    let _ = write!(md, "> {}\n\n", score.summary_line());
966    md.push_str("---\n\n");
967    md.push_str("## Action Plan\n\n");
968    md.push_str(&action_plan);
969    md.push_str("---\n\n");
970    md.push_str("## System Health\n\n```\n");
971    md.push_str(data.health_output.trim_end());
972    md.push_str("\n```\n\n");
973
974    if !data.follow_up_outputs.is_empty() {
975        md.push_str("## Targeted Investigation\n\n");
976        for (topic, output) in &data.follow_up_outputs {
977            let _ = write!(md, "### {}\n\n```\n", topic);
978            md.push_str(output.trim_end());
979            md.push_str("\n```\n\n");
980        }
981    }
982
983    md
984}
985
986/// Same as generate_diagnosis_report but outputs a self-contained HTML file.
987pub async fn generate_diagnosis_report_html() -> String {
988    let version = env!("CARGO_PKG_VERSION");
989    let data = run_diagnosis_phases().await;
990
991    let mut section_refs: Vec<(&str, &str)> = vec![("health_report", data.health_output.as_str())];
992    for (topic, output) in &data.follow_up_outputs {
993        section_refs.push((*topic, output.as_str()));
994    }
995    let score = crate::agent::fix_recipes::score_health(&section_refs);
996    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
997
998    let mut sections: Vec<(&str, String)> = vec![("System Health", data.health_output.clone())];
999    for (topic, output) in &data.follow_up_outputs {
1000        sections.push((*topic, output.clone()));
1001    }
1002
1003    build_html_document(
1004        "Hematite Staged Diagnosis",
1005        &data.timestamp,
1006        &data.hostname,
1007        version,
1008        &score,
1009        &action_plan_html,
1010        &sections,
1011    )
1012}
1013
1014pub async fn generate_report_json() -> String {
1015    let timestamp = now_timestamp_string();
1016    let hostname = hostname_from_env();
1017    let version = env!("CARGO_PKG_VERSION");
1018    let mut obj = serde_json::Map::new();
1019    obj.insert("generated".into(), json!(timestamp));
1020    obj.insert("host".into(), json!(hostname));
1021    obj.insert("hematite_version".into(), json!(version));
1022
1023    let total = REPORT_TOPICS.len();
1024    for (i, (topic, label)) in REPORT_TOPICS.iter().enumerate() {
1025        eprintln!("  [{}/{}] {}...", i + 1, total, label);
1026        let args = json!({"topic": topic});
1027        let value = match crate::tools::host_inspect::inspect_host(&args).await {
1028            Ok(output) => json!({"label": label, "output": output}),
1029            Err(e) => json!({"label": label, "error": e}),
1030        };
1031        obj.insert(topic.to_string(), value);
1032    }
1033
1034    serde_json::to_string_pretty(&serde_json::Value::Object(obj))
1035        .unwrap_or_else(|e| format!("{{\"error\": \"{}\"}}", e))
1036}
1037
1038/// Runs diagnostic topics, writes to `.hematite/reports/health-<timestamp>.md`,
1039/// and returns `(markdown_content, saved_path)`.
1040pub async fn save_report_markdown() -> (String, PathBuf) {
1041    let md = generate_report_markdown().await;
1042    let path = report_path("md");
1043    ensure_parent(&path);
1044    let _ = std::fs::write(&path, &md);
1045    (md, path)
1046}
1047
1048/// Same as `save_report_markdown` but JSON format.
1049pub async fn save_report_json() -> (String, PathBuf) {
1050    let json = generate_report_json().await;
1051    let path = report_path("json");
1052    ensure_parent(&path);
1053    let _ = std::fs::write(&path, &json);
1054    (json, path)
1055}
1056
1057/// Self-contained HTML diagnostic report — double-clickable, no external deps.
1058pub async fn generate_report_html() -> String {
1059    let timestamp = now_timestamp_string();
1060    let mut hostname = hostname_from_env();
1061    let version = env!("CARGO_PKG_VERSION");
1062    let mut sections: Vec<(&str, String)> = Vec::with_capacity(REPORT_TOPICS.len());
1063
1064    let total = REPORT_TOPICS.len();
1065    for (i, (topic, label)) in REPORT_TOPICS.iter().enumerate() {
1066        eprintln!("  [{}/{}] {}...", i + 1, total, label);
1067        let args = json!({"topic": topic});
1068        let output = match crate::tools::host_inspect::inspect_host(&args).await {
1069            Ok(s) => {
1070                if *topic == "hardware" {
1071                    for line in s.lines() {
1072                        let ll = line.to_ascii_lowercase();
1073                        if ll.contains("hostname") || ll.contains("computer name") {
1074                            if let Some(val) = line.split_once(':').map(|x| x.1) {
1075                                let h = val.trim().to_string();
1076                                if !h.is_empty() {
1077                                    hostname = h;
1078                                }
1079                            }
1080                        }
1081                    }
1082                }
1083                s
1084            }
1085            Err(e) => format!("Error: {}", e),
1086        };
1087        sections.push((label, output));
1088    }
1089
1090    let section_refs: Vec<(&str, &str)> = sections.iter().map(|(l, o)| (*l, o.as_str())).collect();
1091    let score = crate::agent::fix_recipes::score_health(&section_refs);
1092    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
1093
1094    build_html_document(
1095        "Hematite Diagnostic Report",
1096        &timestamp,
1097        &hostname,
1098        version,
1099        &score,
1100        &action_plan_html,
1101        &sections,
1102    )
1103}
1104
1105pub async fn save_report_html() -> (String, PathBuf) {
1106    let html = generate_report_html().await;
1107    let path = report_path("html");
1108    ensure_parent(&path);
1109    let _ = std::fs::write(&path, &html);
1110    (html, path)
1111}
1112
1113pub async fn save_diagnosis_report() -> (String, PathBuf) {
1114    let md = generate_diagnosis_report().await;
1115    let path = crate::tools::file_ops::hematite_dir()
1116        .join("reports")
1117        .join(format!("diagnosis-{}.md", now_file_timestamp()));
1118    ensure_parent(&path);
1119    let _ = std::fs::write(&path, &md);
1120    (md, path)
1121}
1122
1123pub async fn save_diagnosis_report_html() -> (String, PathBuf) {
1124    let html = generate_diagnosis_report_html().await;
1125    let path = crate::tools::file_ops::hematite_dir()
1126        .join("reports")
1127        .join(format!("diagnosis-{}.html", now_file_timestamp()));
1128    ensure_parent(&path);
1129    let _ = std::fs::write(&path, &html);
1130    (html, path)
1131}
1132
1133fn build_html_document(
1134    title: &str,
1135    timestamp: &str,
1136    hostname: &str,
1137    version: &str,
1138    score: &crate::agent::fix_recipes::HealthScore,
1139    action_plan_html: &str,
1140    sections: &[(&str, String)],
1141) -> String {
1142    use crate::agent::html_template::{build_html_shell, he, COPY_BUTTON_HTML};
1143
1144    let mut sections_html =
1145        String::with_capacity(sections.iter().map(|(_, o)| o.len() + 64).sum::<usize>());
1146    for (label, output) in sections {
1147        let _ = writeln!(
1148            sections_html,
1149            "<details><summary>{}</summary><pre>{}</pre></details>",
1150            he(label),
1151            he(output.trim_end())
1152        );
1153    }
1154
1155    let content = format!(
1156        r#"<header>
1157<h1>{title}</h1>
1158<div class="meta">
1159  <span>Generated: {timestamp}</span>
1160  <span>Host: {hostname}</span>
1161  <span>Hematite v{version}</span>
1162</div>
1163<div class="score-row">
1164  <div class="grade g{grade}">{grade}</div>
1165  <div class="score-info">
1166    <h2>Health Score: {grade} — {label}</h2>
1167    <p>{summary}</p>
1168  </div>
1169</div>
1170<p class="grade-intro">{intro}</p>
1171{copy_btn}
1172</header>
1173<section>
1174<h2>Action Plan</h2>
1175{action_plan_html}
1176</section>
1177<section>
1178<h2>Diagnostic Data</h2>
1179{sections_html}
1180</section>"#,
1181        title = he(title),
1182        hostname = he(hostname),
1183        timestamp = he(timestamp),
1184        version = he(version),
1185        grade = score.grade,
1186        label = he(score.label),
1187        summary = he(&score.summary_line()),
1188        intro = he(score.grade_intro()),
1189        copy_btn = COPY_BUTTON_HTML,
1190        action_plan_html = action_plan_html,
1191        sections_html = sections_html,
1192    );
1193
1194    let page_title = format!("{} — {}", he(title), he(hostname));
1195    build_html_shell(&page_title, version, &content)
1196}
1197
1198// ── Triage report (IT-first-look, no model required) ─────────────────────────
1199
1200struct TriageData {
1201    timestamp: String,
1202    hostname: String,
1203    sections: Vec<(&'static str, String)>,
1204}
1205
1206async fn run_triage_phases(preset: &str) -> TriageData {
1207    let topics = triage_topics_for_preset(preset);
1208    let total = topics.len();
1209    let timestamp = now_timestamp_string();
1210    let mut hostname = hostname_from_env();
1211    let mut sections: Vec<(&'static str, String)> = Vec::with_capacity(total);
1212
1213    for (i, &(topic, label)) in topics.iter().enumerate() {
1214        eprintln!("  [{}/{}] {}...", i + 1, total, label);
1215        let args = serde_json::json!({"topic": topic});
1216        let output = match crate::tools::host_inspect::inspect_host(&args).await {
1217            Ok(s) => {
1218                if topic == "health_report" {
1219                    for line in s.lines() {
1220                        let ll = line.to_ascii_lowercase();
1221                        if ll.contains("hostname") || ll.contains("computer name") {
1222                            if let Some(val) = line.split_once(':').map(|x| x.1) {
1223                                let h = val.trim().to_string();
1224                                if !h.is_empty() {
1225                                    hostname = h;
1226                                }
1227                            }
1228                        }
1229                    }
1230                }
1231                s
1232            }
1233            Err(e) => format!("Error: {}", e),
1234        };
1235        sections.push((label, output));
1236    }
1237
1238    TriageData {
1239        timestamp,
1240        hostname,
1241        sections,
1242    }
1243}
1244
1245pub async fn generate_triage_report_markdown(preset: &str) -> String {
1246    let title = triage_preset_title(preset);
1247    let data = run_triage_phases(preset).await;
1248    let version = env!("CARGO_PKG_VERSION");
1249
1250    let section_refs: Vec<(&str, &str)> = data
1251        .sections
1252        .iter()
1253        .map(|(l, o)| (*l, o.as_str()))
1254        .collect();
1255    let score = crate::agent::fix_recipes::score_health(&section_refs);
1256    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
1257
1258    let mut md = String::with_capacity(action_plan.len() + data.sections.len() * 512 + 256);
1259    let _ = write!(md, "# {}\n\n", title);
1260    let _ = writeln!(md, "**Generated:** {}  ", data.timestamp);
1261    let _ = writeln!(md, "**Host:** {}  ", data.hostname);
1262    let _ = writeln!(md, "**Hematite:** v{}  ", version);
1263    let _ = write!(
1264        md,
1265        "**Health Score:** {} — {}  \n\n",
1266        score.grade, score.label
1267    );
1268    let _ = write!(md, "> {}\n\n", score.summary_line());
1269    md.push_str("---\n\n## Action Plan\n\n");
1270    md.push_str(&action_plan);
1271    md.push_str("---\n\n");
1272    for (label, output) in &data.sections {
1273        let _ = write!(md, "## {}\n\n```\n", label);
1274        md.push_str(output.trim_end());
1275        md.push_str("\n```\n\n");
1276    }
1277    md
1278}
1279
1280pub async fn generate_triage_report_html(preset: &str) -> String {
1281    let title = triage_preset_title(preset);
1282    let data = run_triage_phases(preset).await;
1283    let version = env!("CARGO_PKG_VERSION");
1284
1285    let section_refs: Vec<(&str, &str)> = data
1286        .sections
1287        .iter()
1288        .map(|(l, o)| (*l, o.as_str()))
1289        .collect();
1290    let score = crate::agent::fix_recipes::score_health(&section_refs);
1291    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
1292
1293    build_html_document(
1294        title,
1295        &data.timestamp,
1296        &data.hostname,
1297        version,
1298        &score,
1299        &action_plan_html,
1300        &data.sections,
1301    )
1302}
1303
1304pub async fn save_triage_report(preset: &str) -> (String, PathBuf) {
1305    let md = generate_triage_report_markdown(preset).await;
1306    let path = crate::tools::file_ops::hematite_dir()
1307        .join("reports")
1308        .join(format!("triage-{}.md", now_file_timestamp()));
1309    ensure_parent(&path);
1310    let _ = std::fs::write(&path, &md);
1311    (md, path)
1312}
1313
1314pub async fn save_triage_report_html(preset: &str) -> (String, PathBuf) {
1315    let html = generate_triage_report_html(preset).await;
1316    let path = crate::tools::file_ops::hematite_dir()
1317        .join("reports")
1318        .join(format!("triage-{}.html", now_file_timestamp()));
1319    ensure_parent(&path);
1320    let _ = std::fs::write(&path, &html);
1321    (html, path)
1322}
1323
1324// ── Fix Plan (--fix "<issue>", no model required) ─────────────────────────────
1325
1326struct FixPlanData {
1327    timestamp: String,
1328    hostname: String,
1329    sections: Vec<(&'static str, String)>,
1330}
1331
1332async fn run_fix_plan_phases(issue: &str) -> FixPlanData {
1333    let initial_topics = topics_for_issue(issue);
1334    let total = initial_topics.len();
1335    let timestamp = now_timestamp_string();
1336    let mut hostname = hostname_from_env();
1337    let mut sections: Vec<(&'static str, String)> = Vec::with_capacity(total);
1338
1339    eprintln!("hematite --fix: \"{}\" ({} check(s))", issue, total);
1340    for (i, &(topic, label)) in initial_topics.iter().enumerate() {
1341        eprintln!("  [{}/{}] {}...", i + 1, total, label);
1342        let args = serde_json::json!({"topic": topic});
1343        let output = match crate::tools::host_inspect::inspect_host(&args).await {
1344            Ok(s) => {
1345                if topic == "health_report" {
1346                    for line in s.lines() {
1347                        let ll = line.to_ascii_lowercase();
1348                        if ll.contains("hostname") || ll.contains("computer name") {
1349                            if let Some(val) = line.split_once(':').map(|x| x.1) {
1350                                let h = val.trim().to_string();
1351                                if !h.is_empty() {
1352                                    hostname = h;
1353                                }
1354                            }
1355                        }
1356                    }
1357                }
1358                s
1359            }
1360            Err(e) => format!("Error: {}", e),
1361        };
1362        sections.push((label, output));
1363    }
1364
1365    let combined: String = {
1366        let total = sections.iter().map(|(_, o)| o.len()).sum::<usize>() + sections.len();
1367        let mut s = String::with_capacity(total);
1368        for (i, (_, o)) in sections.iter().enumerate() {
1369            if i > 0 {
1370                s.push('\n');
1371            }
1372            s.push_str(o);
1373        }
1374        s
1375    };
1376    let ran: Vec<&str> = initial_topics.iter().map(|&(t, _)| t).collect();
1377    let follow_ups = crate::agent::diagnose::fix_follow_up_topics(&combined, &ran);
1378
1379    if !follow_ups.is_empty() {
1380        eprintln!(
1381            "  → {} follow-up check(s) triggered by findings...",
1382            follow_ups.len()
1383        );
1384    }
1385
1386    for (i, &(topic, label)) in follow_ups.iter().enumerate() {
1387        eprintln!("  + [{}/{}] {}...", i + 1, follow_ups.len(), label);
1388        let args = serde_json::json!({"topic": topic});
1389        let output = match crate::tools::host_inspect::inspect_host(&args).await {
1390            Ok(s) => s,
1391            Err(e) => format!("Error: {}", e),
1392        };
1393        sections.push((label, output));
1394    }
1395
1396    FixPlanData {
1397        timestamp,
1398        hostname,
1399        sections,
1400    }
1401}
1402
1403pub async fn generate_fix_plan_markdown(issue: &str) -> String {
1404    let data = run_fix_plan_phases(issue).await;
1405    let version = env!("CARGO_PKG_VERSION");
1406
1407    let section_refs: Vec<(&str, &str)> = data
1408        .sections
1409        .iter()
1410        .map(|(l, o)| (*l, o.as_str()))
1411        .collect();
1412    let score = crate::agent::fix_recipes::score_health(&section_refs);
1413    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
1414
1415    let mut md = String::with_capacity(action_plan.len() + data.sections.len() * 512 + 256);
1416    md.push_str("# Hematite Fix Plan\n\n");
1417    let _ = writeln!(md, "**Issue:** {}  ", issue);
1418    let _ = writeln!(md, "**Generated:** {}  ", data.timestamp);
1419    let _ = writeln!(md, "**Host:** {}  ", data.hostname);
1420    let _ = writeln!(md, "**Hematite:** v{}  ", version);
1421    let _ = write!(
1422        md,
1423        "**Health Score:** {} — {}  \n\n",
1424        score.grade, score.label
1425    );
1426    let _ = write!(md, "> {}\n\n", score.summary_line());
1427    md.push_str("---\n\n## Fix Steps\n\n");
1428    md.push_str(&action_plan);
1429    md.push_str("---\n\n");
1430    for (label, output) in &data.sections {
1431        let _ = write!(md, "## {}\n\n```\n", label);
1432        md.push_str(output.trim_end());
1433        md.push_str("\n```\n\n");
1434    }
1435    md
1436}
1437
1438pub async fn generate_fix_plan_html(issue: &str) -> String {
1439    let data = run_fix_plan_phases(issue).await;
1440    let version = env!("CARGO_PKG_VERSION");
1441
1442    let section_refs: Vec<(&str, &str)> = data
1443        .sections
1444        .iter()
1445        .map(|(l, o)| (*l, o.as_str()))
1446        .collect();
1447    let score = crate::agent::fix_recipes::score_health(&section_refs);
1448    let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(&section_refs);
1449
1450    use crate::agent::html_template::{build_html_shell, he, COPY_BUTTON_HTML};
1451
1452    let mut sections_html = String::with_capacity(data.sections.len() * 512);
1453    for (label, output) in &data.sections {
1454        let _ = writeln!(
1455            sections_html,
1456            "<details><summary>{}</summary><pre>{}</pre></details>",
1457            he(label),
1458            he(output.trim_end())
1459        );
1460    }
1461
1462    let content = format!(
1463        r#"<header>
1464<h1>Fix Plan</h1>
1465<p class="grade-intro" style="margin-bottom:.85rem">Issue: <strong>{issue}</strong></p>
1466<div class="meta">
1467  <span>Generated: {timestamp}</span>
1468  <span>Host: {hostname}</span>
1469  <span>Hematite v{version}</span>
1470</div>
1471<div class="score-row">
1472  <div class="grade g{grade}">{grade}</div>
1473  <div class="score-info">
1474    <h2>Health Score: {grade} — {label}</h2>
1475    <p>{summary}</p>
1476  </div>
1477</div>
1478{copy_btn}
1479</header>
1480<section>
1481<h2>Fix Steps</h2>
1482{action_plan_html}
1483</section>
1484<section>
1485<h2>Diagnostic Data</h2>
1486{sections_html}
1487</section>"#,
1488        issue = he(issue),
1489        hostname = he(&data.hostname),
1490        timestamp = he(&data.timestamp),
1491        version = he(version),
1492        grade = score.grade,
1493        label = he(score.label),
1494        summary = he(&score.summary_line()),
1495        copy_btn = COPY_BUTTON_HTML,
1496        action_plan_html = action_plan_html,
1497        sections_html = sections_html,
1498    );
1499
1500    let page_title = format!("Fix Plan: {} — {}", he(issue), he(&data.hostname));
1501    build_html_shell(&page_title, version, &content)
1502}
1503
1504pub async fn save_fix_plan(issue: &str) -> (String, PathBuf) {
1505    let md = generate_fix_plan_markdown(issue).await;
1506    let path = crate::tools::file_ops::hematite_dir()
1507        .join("reports")
1508        .join(format!("fix-{}.md", now_file_timestamp()));
1509    ensure_parent(&path);
1510    let _ = std::fs::write(&path, &md);
1511    (md, path)
1512}
1513
1514/// Like `save_fix_plan` but also returns the plain-text action plan for immediate
1515/// terminal printing. Returns `(action_plan_text, full_md, path)`.
1516pub async fn save_fix_plan_with_summary(issue: &str) -> (String, String, PathBuf) {
1517    let data = run_fix_plan_phases(issue).await;
1518    let version = env!("CARGO_PKG_VERSION");
1519
1520    let section_refs: Vec<(&str, &str)> = data
1521        .sections
1522        .iter()
1523        .map(|(l, o)| (*l, o.as_str()))
1524        .collect();
1525    let score = crate::agent::fix_recipes::score_health(&section_refs);
1526    let action_plan = crate::agent::fix_recipes::format_action_plan(&section_refs);
1527
1528    let mut md = String::with_capacity(action_plan.len() + data.sections.len() * 512 + 256);
1529    md.push_str("# Hematite Fix Plan\n\n");
1530    let _ = writeln!(md, "**Issue:** {}  ", issue);
1531    let _ = writeln!(md, "**Generated:** {}  ", data.timestamp);
1532    let _ = writeln!(md, "**Host:** {}  ", data.hostname);
1533    let _ = writeln!(md, "**Hematite:** v{}  ", version);
1534    let _ = write!(
1535        md,
1536        "**Health Score:** {} — {}  \n\n",
1537        score.grade, score.label
1538    );
1539    let _ = write!(md, "> {}\n\n", score.summary_line());
1540    md.push_str("---\n\n## Fix Steps\n\n");
1541    md.push_str(&action_plan);
1542    md.push_str("---\n\n");
1543    for (label, output) in &data.sections {
1544        let _ = write!(md, "## {}\n\n```\n", label);
1545        md.push_str(output.trim_end());
1546        md.push_str("\n```\n\n");
1547    }
1548
1549    let path = crate::tools::file_ops::hematite_dir()
1550        .join("reports")
1551        .join(format!("fix-{}.md", now_file_timestamp()));
1552    ensure_parent(&path);
1553    let _ = std::fs::write(&path, &md);
1554
1555    let summary = format!(
1556        "Health Score: {} — {}\n\n{}",
1557        score.grade, score.label, action_plan
1558    );
1559    (summary, md, path)
1560}
1561
1562pub async fn save_fix_plan_html(issue: &str) -> (String, PathBuf) {
1563    let html = generate_fix_plan_html(issue).await;
1564    let path = crate::tools::file_ops::hematite_dir()
1565        .join("reports")
1566        .join(format!("fix-{}.html", now_file_timestamp()));
1567    ensure_parent(&path);
1568    let _ = std::fs::write(&path, &html);
1569    (html, path)
1570}
1571
1572// ── Direct topic inspection (--inspect, /query) ──────────────────────────────
1573
1574/// Run one or more inspect_host topics by name and return combined plain-text output.
1575/// `topics_csv` is a comma-separated list: e.g. "wifi,latency,dns_cache".
1576pub async fn generate_inspect_output(topics_csv: &str) -> String {
1577    let topics: Vec<&str> = topics_csv
1578        .split(',')
1579        .map(str::trim)
1580        .filter(|s| !s.is_empty())
1581        .collect();
1582
1583    if topics.is_empty() {
1584        return "No topics specified. Example: hematite --inspect wifi,latency,dns_cache\n\
1585                Run `hematite --inventory` to list all 128+ available topics.\n"
1586            .to_string();
1587    }
1588
1589    let total = topics.len();
1590    if total > 1 {
1591        eprintln!("hematite --inspect: {} topic(s)", total);
1592    }
1593    let mut out = String::new();
1594    for (i, topic) in topics.iter().enumerate() {
1595        if total > 1 {
1596            eprintln!("  [{}/{}] {}...", i + 1, total, topic);
1597        }
1598        let args = json!({"topic": topic});
1599        let result = match crate::tools::host_inspect::inspect_host(&args).await {
1600            Ok(s) => s,
1601            Err(e) => format!("Error ({}): {}", topic, e),
1602        };
1603        if total > 1 {
1604            let _ = writeln!(out, "─── {} ───", topic);
1605        }
1606        out.push_str(result.trim_end());
1607        out.push('\n');
1608        if total > 1 {
1609            out.push('\n');
1610        }
1611    }
1612    out
1613}
1614
1615/// Run inspect topics and optionally save as a report file.
1616/// Returns `(content, saved_path_option)`.
1617pub async fn run_inspect_topics(
1618    topics_csv: &str,
1619    fmt: &str,
1620    save: bool,
1621) -> (String, Option<PathBuf>) {
1622    let content = generate_inspect_output(topics_csv).await;
1623
1624    if !save {
1625        return (content, None);
1626    }
1627
1628    let path = crate::tools::file_ops::hematite_dir()
1629        .join("reports")
1630        .join(format!("inspect-{}.{}", now_file_timestamp(), fmt));
1631    ensure_parent(&path);
1632    let _ = std::fs::write(&path, &content);
1633    (content, Some(path))
1634}
1635
1636/// Route a natural-language query to the appropriate inspect_host topics and run them.
1637/// Uses `all_host_inspection_topics()` for multi-topic detection, falls back to
1638/// `preferred_host_inspection_topic()` for single-topic, then "summary" if nothing matches.
1639pub async fn generate_query_output(query: &str) -> String {
1640    use crate::agent::routing::{all_host_inspection_topics, preferred_host_inspection_topic};
1641
1642    let detected = all_host_inspection_topics(query);
1643    let topics: Vec<&str> = if !detected.is_empty() {
1644        detected
1645    } else {
1646        match preferred_host_inspection_topic(query) {
1647            Some(t) => vec![t],
1648            None => vec!["summary"],
1649        }
1650    };
1651
1652    let total = topics.len();
1653    eprintln!("hematite --query: {} topic(s) matched", total);
1654    let mut out = String::new();
1655    for (i, topic) in topics.iter().enumerate() {
1656        eprintln!("  [{}/{}] {}...", i + 1, total, topic);
1657        let args = json!({"topic": topic});
1658        let result = match crate::tools::host_inspect::inspect_host(&args).await {
1659            Ok(s) => s,
1660            Err(e) => format!("Error ({}): {}", topic, e),
1661        };
1662        if total > 1 {
1663            let _ = writeln!(out, "─── {} ───", topic);
1664        }
1665        out.push_str(result.trim_end());
1666        out.push('\n');
1667        if total > 1 {
1668            out.push('\n');
1669        }
1670    }
1671    out
1672}
1673
1674/// Save arbitrary markdown content as a dark-theme HTML page.
1675/// Returns `(html_string, saved_path)`. Title defaults to a timestamp slug
1676/// if empty. Saves to `.hematite/reports/research-DATE.html`.
1677pub fn save_research_html(title: &str, body_md: &str) -> (String, PathBuf) {
1678    use crate::agent::html_template::{build_html_shell, he, markdown_to_html, COPY_BUTTON_HTML};
1679    let version = env!("CARGO_PKG_VERSION");
1680    let timestamp = now_timestamp_string();
1681    let display_title = if title.trim().is_empty() {
1682        format!("Research — {}", &timestamp[..10])
1683    } else {
1684        title.to_string()
1685    };
1686
1687    let body_html = markdown_to_html(body_md);
1688    let content = format!(
1689        r#"<header>
1690<h1>{title}</h1>
1691<div class="meta">
1692  <span>Saved: {timestamp}</span>
1693  <span>Hematite v{version}</span>
1694</div>
1695{copy_btn}
1696</header>
1697<section>
1698{body_html}
1699</section>"#,
1700        title = he(&display_title),
1701        timestamp = he(&timestamp),
1702        version = he(version),
1703        copy_btn = COPY_BUTTON_HTML,
1704        body_html = body_html,
1705    );
1706
1707    let html = build_html_shell(&display_title, version, &content);
1708    let path = crate::tools::file_ops::hematite_dir()
1709        .join("reports")
1710        .join(format!("research-{}.html", now_file_timestamp()));
1711    ensure_parent(&path);
1712    let _ = std::fs::write(&path, &html);
1713    (html, path)
1714}
1715
1716fn report_path(ext: &str) -> PathBuf {
1717    crate::tools::file_ops::hematite_dir()
1718        .join("reports")
1719        .join(format!("health-{}.{}", now_file_timestamp(), ext))
1720}
1721
1722fn ensure_parent(path: &Path) {
1723    if let Some(parent) = path.parent() {
1724        let _ = std::fs::create_dir_all(parent);
1725    }
1726}
1727
1728fn now_timestamp_string() -> String {
1729    let now = unix_now();
1730    let (y, mo, d, h, mi, s) = epoch_to_ymd_hms(now);
1731    format!(
1732        "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC",
1733        y, mo, d, h, mi, s
1734    )
1735}
1736
1737fn now_file_timestamp() -> String {
1738    let now = unix_now();
1739    let (y, mo, d, h, mi, _s) = epoch_to_ymd_hms(now);
1740    format!("{:04}-{:02}-{:02}_{:02}-{:02}", y, mo, d, h, mi)
1741}
1742
1743fn unix_now() -> u64 {
1744    std::time::SystemTime::now()
1745        .duration_since(std::time::UNIX_EPOCH)
1746        .unwrap_or_default()
1747        .as_secs()
1748}
1749
1750fn hostname_from_env() -> String {
1751    std::env::var("COMPUTERNAME")
1752        .or_else(|_| std::env::var("HOSTNAME"))
1753        .unwrap_or_else(|_| "unknown".to_string())
1754}
1755
1756/// Gregorian calendar decomposition of a Unix timestamp (accurate 1970–2100).
1757fn epoch_to_ymd_hms(epoch: u64) -> (u32, u32, u32, u32, u32, u32) {
1758    let s = (epoch % 60) as u32;
1759    let mi = ((epoch / 60) % 60) as u32;
1760    let h = ((epoch / 3600) % 24) as u32;
1761    let days = epoch / 86400;
1762
1763    let years_400 = days / 146097;
1764    let rem = days % 146097;
1765    let years_100 = rem.min(146096) / 36524;
1766    let rem = rem - years_100 * 36524;
1767    let years_4 = rem / 1461;
1768    let rem = rem % 1461;
1769    let years_1 = rem.min(1460) / 365;
1770    let rem = rem - years_1 * 365;
1771
1772    let year = (1970 + years_400 * 400 + years_100 * 100 + years_4 * 4 + years_1) as u32;
1773    let leap = u32::from(
1774        year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)),
1775    );
1776    let month_days: [u32; 12] = [31, 28 + leap, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
1777    let mut rem = rem as u32;
1778    let mut month = 1u32;
1779    for &md in &month_days {
1780        if rem < md {
1781            break;
1782        }
1783        rem -= md;
1784        month += 1;
1785    }
1786    let day = rem + 1;
1787    (year, month, day, h, mi, s)
1788}