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
14const 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
79fn 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
662fn 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
703pub 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
723pub 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(§ion_refs);
863 let action_plan = crate::agent::fix_recipes::format_action_plan(§ion_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 §ions {
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
941pub 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(§ion_refs);
952 let action_plan = crate::agent::fix_recipes::format_action_plan(§ion_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
986pub 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(§ion_refs);
996 let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(§ion_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 §ions,
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
1038pub 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
1048pub 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
1057pub 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(§ion_refs);
1092 let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(§ion_refs);
1093
1094 build_html_document(
1095 "Hematite Diagnostic Report",
1096 ×tamp,
1097 &hostname,
1098 version,
1099 &score,
1100 &action_plan_html,
1101 §ions,
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
1198struct 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(§ion_refs);
1256 let action_plan = crate::agent::fix_recipes::format_action_plan(§ion_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(§ion_refs);
1291 let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(§ion_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
1324struct 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(§ion_refs);
1413 let action_plan = crate::agent::fix_recipes::format_action_plan(§ion_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(§ion_refs);
1448 let action_plan_html = crate::agent::fix_recipes::format_action_plan_html(§ion_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
1514pub 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(§ion_refs);
1526 let action_plan = crate::agent::fix_recipes::format_action_plan(§ion_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
1572pub 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
1615pub 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
1636pub 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
1674pub 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 — {}", ×tamp[..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(×tamp),
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
1756fn 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}